diff --git a/apps/web/index.html b/apps/web/index.html index 9f0329b602..d701a4b313 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -88,6 +88,7 @@ /> T3 Code (Alpha) +
diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index e91266d65f..7213c81b06 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -4,7 +4,10 @@ import { memo, useMemo } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useStore } from "../store"; -import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; +import { + createProjectSelectorByRef, + createThreadBranchToolbarSnapshotSelectorByRef, +} from "../storeSelectors"; import { type EnvMode, type EnvironmentOption, @@ -42,7 +45,10 @@ export const BranchToolbar = memo(function BranchToolbar({ () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); - const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); + const serverThreadSelector = useMemo( + () => createThreadBranchToolbarSnapshotSelectorByRef(threadRef), + [threadRef], + ); const serverThread = useStore(serverThreadSelector); const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 76f64d93fe..bea6d353b8 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -22,7 +22,10 @@ import { useGitStatus } from "../lib/gitStatusState"; import { newCommandId } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; import { useStore } from "../store"; -import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; +import { + createProjectSelectorByRef, + createThreadBranchActionSnapshotSelectorByRef, +} from "../storeSelectors"; import { deriveLocalBranchNameFromRemoteRef, resolveBranchSelectionTarget, @@ -87,9 +90,11 @@ export function BranchToolbarBranchSelector({ () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); - const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); + const serverThreadSelector = useMemo( + () => createThreadBranchActionSnapshotSelectorByRef(threadRef), + [threadRef], + ); const serverThread = useStore(serverThreadSelector); - const serverSession = serverThread?.session ?? null; const setThreadBranchAction = useStore((store) => store.setThreadBranch); const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), @@ -126,7 +131,7 @@ export function BranchToolbarBranchSelector({ (branch: string | null, worktreePath: string | null) => { if (!activeThreadId || !activeProject) return; const api = readEnvironmentApi(environmentId); - if (serverSession && worktreePath !== activeWorktreePath && api) { + if (serverThread?.hasSession && worktreePath !== activeWorktreePath && api) { void api.orchestration .dispatchCommand({ type: "thread.session.stop", @@ -164,7 +169,7 @@ export function BranchToolbarBranchSelector({ [ activeThreadId, activeProject, - serverSession, + serverThread?.hasSession, activeWorktreePath, hasServerThread, setThreadBranchAction, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 366f923157..5b5610625f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -60,6 +60,11 @@ const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); const highlighterPromiseCache = new Map>(); +const CHAT_MARKDOWN_REMARK_PLUGINS = [remarkGfm]; + +function markdownUrlTransform(href: string) { + return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); +} function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); @@ -239,9 +244,6 @@ function SuspenseShikiCodeBlock({ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); - const markdownUrlTransform = useCallback((href: string) => { - return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); - }, []); const markdownComponents = useMemo( () => ({ a({ node: _node, href, ...props }) { @@ -295,7 +297,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { return (
diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index a753a71b39..b5c70eaa5b 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -226,12 +226,29 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { ); } +type ThreadStartSnapshot = { + latestTurn: Thread["latestTurn"] | null; + session: Pick< + NonNullable, + "provider" | "status" | "activeTurnId" | "orchestrationStatus" + > | null; + messageCount?: number; + messages?: { length: number } | null; +}; + +export function threadSnapshotHasStarted(thread: ThreadStartSnapshot | null | undefined): boolean { + const messageCount = thread?.messageCount ?? thread?.messages?.length ?? 0; + return Boolean( + thread && (thread.latestTurn !== null || messageCount > 0 || thread.session !== null), + ); +} + export function deriveLockedProvider(input: { - thread: Thread | null | undefined; + thread: ThreadStartSnapshot | null | undefined; selectedProvider: ProviderKind | null; threadProvider: ProviderKind | null; }): ProviderKind | null { - if (!threadHasStarted(input.thread)) { + if (!threadSnapshotHasStarted(input.thread)) { return null; } return input.thread?.session?.provider ?? input.threadProvider ?? input.selectedProvider ?? null; @@ -293,7 +310,7 @@ export interface LocalDispatchSnapshot { } export function createLocalDispatchSnapshot( - activeThread: Thread | undefined, + activeThread: Thread | Pick | undefined, options?: { preparingWorktree?: boolean }, ): LocalDispatchSnapshot { const latestTurn = activeThread?.latestTurn ?? null; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7a5a2875cc..49b0f8f669 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -19,6 +19,7 @@ import { RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; +import type { UnifiedSettings } from "@t3tools/contracts/settings"; import { parseScopedThreadKey, scopedThreadKey, @@ -28,7 +29,18 @@ import { import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + forwardRef, + memo, + useCallback, + useEffect, + useEffectEvent, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; @@ -42,20 +54,14 @@ import { parseStandaloneComposerSlashCommand, } from "../composer-logic"; import { - deriveCompletionDividerBeforeEntryId, + deriveActivePlanState, derivePendingApprovals, derivePendingUserInputs, derivePhase, - deriveTimelineEntries, - deriveActiveWorkStartedAt, - deriveActivePlanState, - findSidebarProposedPlan, findLatestProposedPlan, - deriveWorkLogEntries, + findSidebarProposedPlan, hasActionableProposedPlan, - hasToolActivityForTurn, isLatestTurnSettled, - formatElapsed, } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { @@ -67,10 +73,21 @@ import { } from "../pendingUserInput"; import { selectProjectsAcrossEnvironments, - selectThreadsAcrossEnvironments, + selectThreadShellsAcrossEnvironments, useStore, } from "../store"; -import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; +import { + createThreadBranchToolbarSnapshotSelectorByRef, + createThreadComposerSnapshotSelectorByRef, + createProjectSelectorByRef, + createThreadActivitiesSelectorByRef, + createThreadConversationRuntimeSelectorByRef, + createThreadMessageIdsSelectorByRef, + createThreadPendingSnapshotSelectorByRef, + createThreadProposedPlansSelectorByRef, + createThreadRuntimeSnapshotSelectorByRef, + createThreadStaticShellSelectorByRef, +} from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, @@ -85,10 +102,8 @@ import { type ChatMessage, type SessionPhase, type Thread, - type TurnDiffSummary, } from "../types"; import { useTheme } from "../hooks/useTheme"; -import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -131,7 +146,7 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; -import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { MessagesTimelineContainer } from "./chat/MessagesTimelineContainer"; import { ChatHeader } from "./chat/ChatHeader"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; @@ -166,15 +181,17 @@ import { useServerAvailableEditors, useServerConfig, useServerKeybindings, + useServerProviders, } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; +const EMPTY_CHAT_MESSAGES: ChatMessage[] = []; const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; +const EMPTY_TURN_DIFF_SUMMARIES: Thread["turnDiffSummaries"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; -const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID: Record = {}; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; type ThreadPlanCatalogEntry = Pick; @@ -187,7 +204,7 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog let previousEntries = new Map< ThreadId, { - shell: object | null; + hasThread: boolean; proposedPlanIds: readonly string[] | undefined; proposedPlansById: Record | undefined; entry: ThreadPlanCatalogEntry; @@ -201,7 +218,7 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog const nextEntries = new Map< ThreadId, { - shell: object | null; + hasThread: boolean; proposedPlanIds: readonly string[] | undefined; proposedPlansById: Record | undefined; entry: ThreadPlanCatalogEntry; @@ -211,7 +228,7 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog let changed = !sameThreadIds; for (const threadId of threadIds) { - let shell: object | undefined; + let hasThread = false; let proposedPlanIds: readonly string[] | undefined; let proposedPlansById: Record | undefined; @@ -220,7 +237,7 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog if (!matchedShell) { continue; } - shell = matchedShell; + hasThread = true; proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as | Record @@ -228,11 +245,11 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog break; } - if (!shell) { + if (!hasThread) { const previous = previousEntries.get(threadId); if ( previous && - previous.shell === null && + !previous.hasThread && previous.proposedPlanIds === undefined && previous.proposedPlansById === undefined ) { @@ -241,7 +258,7 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog } changed = true; nextEntries.set(threadId, { - shell: null, + hasThread: false, proposedPlanIds: undefined, proposedPlansById: undefined, entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS }, @@ -252,7 +269,7 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog const previous = previousEntries.get(threadId); if ( previous && - previous.shell === shell && + previous.hasThread && previous.proposedPlanIds === proposedPlanIds && previous.proposedPlansById === proposedPlansById ) { @@ -271,7 +288,7 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog : EMPTY_PROPOSED_PLANS; const entry = { id: threadId, proposedPlans }; nextEntries.set(threadId, { - shell, + hasThread: true, proposedPlanIds, proposedPlansById, entry, @@ -333,12 +350,15 @@ interface TerminalLaunchContext { type PersistentTerminalLaunchContext = Pick; function useLocalDispatchState(input: { - activeThread: Thread | undefined; - activeLatestTurn: Thread["latestTurn"] | null; - phase: SessionPhase; - activePendingApproval: ApprovalRequestId | null; - activePendingUserInput: ApprovalRequestId | null; - threadError: string | null | undefined; + getActiveThreadSnapshot: () => Pick | undefined; + getAcknowledgementSnapshot: () => { + latestTurn: Thread["latestTurn"] | null; + phase: SessionPhase; + session: Thread["session"] | null; + hasPendingApproval: boolean; + hasPendingUserInput: boolean; + threadError: string | null | undefined; + }; }) { const [localDispatch, setLocalDispatch] = useState(null); @@ -351,51 +371,51 @@ function useLocalDispatchState(input: { ? current : { ...current, preparingWorktree }; } - return createLocalDispatchSnapshot(input.activeThread, options); + return createLocalDispatchSnapshot(input.getActiveThreadSnapshot(), options); }); }, - [input.activeThread], + [input], ); const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); }, []); - const serverAcknowledgedLocalDispatch = useMemo( - () => - hasServerAcknowledgedLocalDispatch({ - localDispatch, - phase: input.phase, - latestTurn: input.activeLatestTurn, - session: input.activeThread?.session ?? null, - hasPendingApproval: input.activePendingApproval !== null, - hasPendingUserInput: input.activePendingUserInput !== null, - threadError: input.threadError, - }), - [ - input.activeLatestTurn, - input.activePendingApproval, - input.activePendingUserInput, - input.activeThread?.session, - input.phase, - input.threadError, - localDispatch, - ], - ); - useEffect(() => { - if (!serverAcknowledgedLocalDispatch) { + if (!localDispatch) { return; } - resetLocalDispatch(); - }, [resetLocalDispatch, serverAcknowledgedLocalDispatch]); + + const checkAcknowledgement = () => { + const snapshot = input.getAcknowledgementSnapshot(); + if ( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: snapshot.phase, + latestTurn: snapshot.latestTurn, + session: snapshot.session, + hasPendingApproval: snapshot.hasPendingApproval, + hasPendingUserInput: snapshot.hasPendingUserInput, + threadError: snapshot.threadError, + }) + ) { + resetLocalDispatch(); + } + }; + + checkAcknowledgement(); + const unsubscribe = useStore.subscribe(checkAcknowledgement); + return () => { + unsubscribe(); + }; + }, [input, localDispatch, resetLocalDispatch]); return { beginLocalDispatch, resetLocalDispatch, localDispatchStartedAt: localDispatch?.startedAt ?? null, isPreparingWorktree: localDispatch?.preparingWorktree ?? false, - isSendBusy: localDispatch !== null && !serverAcknowledgedLocalDispatch, + isSendBusy: localDispatch !== null, }; } @@ -422,10 +442,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra closeShortcutLabel, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { - const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const serverThreadWorkspace = useStore( + useMemo(() => createThreadBranchToolbarSnapshotSelectorByRef(threadRef), [threadRef]), + ); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const projectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + const projectRef = serverThreadWorkspace + ? scopeProjectRef(serverThreadWorkspace.environmentId, serverThreadWorkspace.projectId) : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; @@ -439,7 +461,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal); const [localFocusRequestId, setLocalFocusRequestId] = useState(0); - const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const worktreePath = serverThreadWorkspace?.worktreePath ?? draftThread?.worktreePath ?? null; const effectiveWorktreePath = useMemo(() => { if (launchContext !== null) { return launchContext.worktreePath; @@ -572,61 +594,1097 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ); }); -export default function ChatView(props: ChatViewProps) { - const { environmentId, threadId, routeKind, onDiffPanelOpen } = props; - const draftId = routeKind === "draft" ? props.draftId : null; - const routeThreadRef = useMemo( - () => scopeThreadRef(environmentId, threadId), - [environmentId, threadId], +interface ChatMessagesPaneHandle { + addOptimisticUserMessage: (message: ChatMessage) => void; + removeOptimisticUserMessage: (messageId: MessageId) => void; + clearOptimisticUserMessages: () => void; + scheduleStickToBottom: () => void; + forceStickToBottom: () => void; + scrollToBottom: (behavior?: ScrollBehavior) => void; + openExpandedImage: (preview: ExpandedImagePreview) => void; +} + +interface ChatMessagesPaneProps { + activeThread: Pick; + draftActivities: Thread["activities"]; + draftMessages: ChatMessage[]; + draftProposedPlans: Thread["proposedPlans"]; + draftTurnDiffSummaries: Thread["turnDiffSummaries"]; + draftLatestTurn: Thread["latestTurn"] | null; + draftSession: Thread["session"] | null; + isConnecting: boolean; + isRevertingCheckpoint: boolean; + isServerThread: boolean; + localDispatchStartedAt: string | null; + markdownCwd: string | undefined; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onRevertToTurnCount: (turnCount: number) => void; + resetKey: string; + resolvedTheme: "light" | "dark"; + routeThreadRef: ScopedThreadRef; + serverMessageIds: readonly MessageId[]; + isSendBusy: boolean; + shouldAutoScrollRef: React.MutableRefObject; + timestampFormat: UnifiedSettings["timestampFormat"]; + workspaceRoot: string | undefined; +} + +const ChatMessagesPane = memo( + forwardRef(function ChatMessagesPane(props, ref) { + const serverThreadRuntime = useStore( + useMemo( + () => + createThreadConversationRuntimeSelectorByRef( + props.isServerThread ? props.routeThreadRef : null, + ), + [props.isServerThread, props.routeThreadRef], + ), + ); + const activeLatestTurn = props.isServerThread + ? (serverThreadRuntime?.latestTurn ?? null) + : props.draftLatestTurn; + const activeThreadSession = props.isServerThread + ? (serverThreadRuntime?.session ?? null) + : props.draftSession; + const phase = props.isServerThread + ? (serverThreadRuntime?.phase ?? derivePhase(null)) + : derivePhase(props.draftSession ?? null); + const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThreadSession); + const isWorking = + phase === "running" || props.isSendBusy || props.isConnecting || props.isRevertingCheckpoint; + const activeTurnInProgress = isWorking || !latestTurnSettled; + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [expandedImage, setExpandedImage] = useState(null); + const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + const optimisticUserMessagesRef = useRef(optimisticUserMessages); + optimisticUserMessagesRef.current = optimisticUserMessages; + const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< + Record + >({}); + const messagesScrollRef = useRef(null); + const [messagesScrollElement, setMessagesScrollElement] = useState(null); + const lastKnownScrollTopRef = useRef(0); + const isPointerScrollActiveRef = useRef(false); + const lastTouchClientYRef = useRef(null); + const pendingUserScrollUpIntentRef = useRef(false); + const pendingAutoScrollFrameRef = useRef(null); + const pendingInteractionAnchorRef = useRef<{ + element: HTMLElement; + top: number; + } | null>(null); + const pendingInteractionAnchorFrameRef = useRef(null); + const attachmentPreviewHandoffByMessageIdRef = useRef>({}); + const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); + + useEffect(() => { + attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; + }, [attachmentPreviewHandoffByMessageId]); + + const clearAttachmentPreviewHandoff = useCallback( + (messageId: MessageId, previewUrls?: ReadonlyArray) => { + delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; + const currentPreviewUrls = + previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; + setAttachmentPreviewHandoffByMessageId((existing) => { + if (!(messageId in existing)) { + return existing; + } + const next = { ...existing }; + delete next[messageId]; + attachmentPreviewHandoffByMessageIdRef.current = next; + return next; + }); + for (const previewUrl of currentPreviewUrls) { + revokeBlobPreviewUrl(previewUrl); + } + }, + [], + ); + + const clearAttachmentPreviewHandoffs = useCallback(() => { + attachmentPreviewPromotionInFlightByMessageIdRef.current = {}; + for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { + for (const previewUrl of previewUrls) { + revokeBlobPreviewUrl(previewUrl); + } + } + attachmentPreviewHandoffByMessageIdRef.current = {}; + setAttachmentPreviewHandoffByMessageId({}); + }, []); + + const clearOptimisticUserMessages = useCallback(() => { + setOptimisticUserMessages((existing) => { + for (const message of existing) { + revokeUserMessagePreviewUrls(message); + } + return existing.length === 0 ? existing : []; + }); + }, []); + + const addOptimisticUserMessage = useCallback((message: ChatMessage) => { + setOptimisticUserMessages((existing) => [...existing, message]); + }, []); + + const removeOptimisticUserMessage = useCallback((messageId: MessageId) => { + setOptimisticUserMessages((existing) => { + const removedMessages = existing.filter((message) => message.id === messageId); + for (const message of removedMessages) { + revokeUserMessagePreviewUrls(message); + } + const nextMessages = existing.filter((message) => message.id !== messageId); + return nextMessages.length === existing.length ? existing : nextMessages; + }); + }, []); + + const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { + if (previewUrls.length === 0) { + return; + } + + const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; + for (const previewUrl of previousPreviewUrls) { + if (!previewUrls.includes(previewUrl)) { + revokeBlobPreviewUrl(previewUrl); + } + } + + setAttachmentPreviewHandoffByMessageId((existing) => { + const next = { + ...existing, + [messageId]: previewUrls, + }; + attachmentPreviewHandoffByMessageIdRef.current = next; + return next; + }); + }, []); + + const scrollToBottom = useCallback( + (behavior: ScrollBehavior = "auto") => { + const scrollContainer = messagesScrollRef.current; + if (!scrollContainer) { + return; + } + scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); + lastKnownScrollTopRef.current = scrollContainer.scrollTop; + props.shouldAutoScrollRef.current = true; + }, + [props.shouldAutoScrollRef], + ); + + const cancelPendingStickToBottom = useCallback(() => { + const pendingFrame = pendingAutoScrollFrameRef.current; + if (pendingFrame === null) { + return; + } + pendingAutoScrollFrameRef.current = null; + window.cancelAnimationFrame(pendingFrame); + }, []); + + const cancelPendingInteractionAnchorAdjustment = useCallback(() => { + const pendingFrame = pendingInteractionAnchorFrameRef.current; + if (pendingFrame === null) { + return; + } + pendingInteractionAnchorFrameRef.current = null; + window.cancelAnimationFrame(pendingFrame); + }, []); + + const scheduleStickToBottom = useCallback(() => { + if (pendingAutoScrollFrameRef.current !== null) { + return; + } + pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { + pendingAutoScrollFrameRef.current = null; + scrollToBottom(); + }); + }, [scrollToBottom]); + + const forceStickToBottom = useCallback(() => { + props.shouldAutoScrollRef.current = true; + cancelPendingStickToBottom(); + scrollToBottom(); + scheduleStickToBottom(); + }, [ + cancelPendingStickToBottom, + props.shouldAutoScrollRef, + scheduleStickToBottom, + scrollToBottom, + ]); + + useImperativeHandle( + ref, + () => ({ + addOptimisticUserMessage, + removeOptimisticUserMessage, + clearOptimisticUserMessages, + scheduleStickToBottom, + forceStickToBottom, + scrollToBottom, + openExpandedImage: (preview) => { + setExpandedImage(preview); + }, + }), + [ + addOptimisticUserMessage, + clearOptimisticUserMessages, + forceStickToBottom, + removeOptimisticUserMessage, + scheduleStickToBottom, + scrollToBottom, + ], + ); + + const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { + messagesScrollRef.current = element; + setMessagesScrollElement(element); + }, []); + + const onMessagesClickCapture = useCallback( + (event: React.MouseEvent) => { + const scrollContainer = messagesScrollRef.current; + if (!scrollContainer || !(event.target instanceof Element)) return; + + const trigger = event.target.closest( + "button, summary, [role='button'], [data-scroll-anchor-target]", + ); + if (!trigger || !scrollContainer.contains(trigger)) return; + if (trigger.closest("[data-scroll-anchor-ignore]")) return; + + pendingInteractionAnchorRef.current = { + element: trigger, + top: trigger.getBoundingClientRect().top, + }; + + cancelPendingInteractionAnchorAdjustment(); + pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame(() => { + pendingInteractionAnchorFrameRef.current = null; + const anchor = pendingInteractionAnchorRef.current; + pendingInteractionAnchorRef.current = null; + const activeScrollContainer = messagesScrollRef.current; + if (!anchor || !activeScrollContainer) return; + if (!anchor.element.isConnected || !activeScrollContainer.contains(anchor.element)) + return; + + const nextTop = anchor.element.getBoundingClientRect().top; + const delta = nextTop - anchor.top; + if (Math.abs(delta) < 0.5) return; + + activeScrollContainer.scrollTop += delta; + lastKnownScrollTopRef.current = activeScrollContainer.scrollTop; + }); + }, + [cancelPendingInteractionAnchorAdjustment], + ); + + const onMessagesScroll = useCallback(() => { + const scrollContainer = messagesScrollRef.current; + if (!scrollContainer) return; + const currentScrollTop = scrollContainer.scrollTop; + const isNearBottom = isScrollContainerNearBottom(scrollContainer); + + if (!props.shouldAutoScrollRef.current && isNearBottom) { + props.shouldAutoScrollRef.current = true; + pendingUserScrollUpIntentRef.current = false; + } else if (props.shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { + const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; + if (scrolledUp && !isNearBottom) { + props.shouldAutoScrollRef.current = false; + } + pendingUserScrollUpIntentRef.current = false; + } else if (props.shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { + const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; + if (scrolledUp && !isNearBottom) { + props.shouldAutoScrollRef.current = false; + } + } else if (props.shouldAutoScrollRef.current && !isNearBottom) { + const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; + if (scrolledUp) { + props.shouldAutoScrollRef.current = false; + } + } + + setShowScrollToBottom(!props.shouldAutoScrollRef.current); + lastKnownScrollTopRef.current = currentScrollTop; + }, [props.shouldAutoScrollRef]); + + const onMessagesWheel = useCallback((event: React.WheelEvent) => { + if (event.deltaY < 0) { + pendingUserScrollUpIntentRef.current = true; + } + }, []); + + const onMessagesPointerDown = useCallback((_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = true; + }, []); + + const onMessagesPointerUp = useCallback((_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = false; + }, []); + + const onMessagesPointerCancel = useCallback((_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = false; + }, []); + + const onMessagesTouchStart = useCallback((event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + lastTouchClientYRef.current = touch.clientY; + }, []); + + const onMessagesTouchMove = useCallback((event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + const previousTouchY = lastTouchClientYRef.current; + if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { + pendingUserScrollUpIntentRef.current = true; + } + lastTouchClientYRef.current = touch.clientY; + }, []); + + const onMessagesTouchEnd = useCallback((_event: React.TouchEvent) => { + lastTouchClientYRef.current = null; + }, []); + + useEffect(() => { + return () => { + cancelPendingStickToBottom(); + cancelPendingInteractionAnchorAdjustment(); + clearAttachmentPreviewHandoffs(); + for (const message of optimisticUserMessagesRef.current) { + revokeUserMessagePreviewUrls(message); + } + }; + }, [ + cancelPendingInteractionAnchorAdjustment, + cancelPendingStickToBottom, + clearAttachmentPreviewHandoffs, + ]); + + useLayoutEffect(() => { + props.shouldAutoScrollRef.current = true; + scheduleStickToBottom(); + const timeout = window.setTimeout(() => { + const scrollContainer = messagesScrollRef.current; + if (!scrollContainer || isScrollContainerNearBottom(scrollContainer)) { + return; + } + scheduleStickToBottom(); + }, 96); + return () => { + window.clearTimeout(timeout); + }; + }, [props.activeThread.id, props.shouldAutoScrollRef, scheduleStickToBottom]); + + useEffect(() => { + if (!props.isServerThread || props.serverMessageIds.length === 0) { + return; + } + const serverIds = new Set(props.serverMessageIds); + const removedMessages = optimisticUserMessagesRef.current.filter((message) => + serverIds.has(message.id), + ); + if (removedMessages.length === 0) { + return; + } + + const timer = window.setTimeout(() => { + setOptimisticUserMessages((existing) => + existing.filter((message) => !serverIds.has(message.id)), + ); + }, 0); + + for (const removedMessage of removedMessages) { + const previewUrls = collectUserMessageBlobPreviewUrls(removedMessage); + if (previewUrls.length > 0) { + handoffAttachmentPreviews(removedMessage.id, previewUrls); + continue; + } + revokeUserMessagePreviewUrls(removedMessage); + } + + return () => { + window.clearTimeout(timer); + }; + }, [handoffAttachmentPreviews, props.isServerThread, props.serverMessageIds]); + + useEffect(() => { + clearOptimisticUserMessages(); + clearAttachmentPreviewHandoffs(); + setExpandedImage(null); + }, [clearAttachmentPreviewHandoffs, clearOptimisticUserMessages, props.resetKey]); + + return ( + <> +
+
+ setExpandedImage(preview)} + onOpenTurnDiff={props.onOpenTurnDiff} + onRevertToTurnCount={props.onRevertToTurnCount} + optimisticUserMessages={optimisticUserMessages} + phase={phase} + resolvedTheme={props.resolvedTheme} + scheduleStickToBottom={scheduleStickToBottom} + scrollContainer={messagesScrollElement} + shouldAutoScrollRef={props.shouldAutoScrollRef} + threadRef={props.isServerThread ? props.routeThreadRef : null} + timestampFormat={props.timestampFormat} + workspaceRoot={props.workspaceRoot} + /> +
+ + {showScrollToBottom && ( +
+ +
+ )} +
+ + {expandedImage ? ( + setExpandedImage(null)} /> + ) : null} + + ); + }), +); + +interface ChatComposerContainerProps { + composerRef: React.RefObject; + composerDraftTarget: ScopedThreadRef | DraftId; + environmentId: EnvironmentId; + routeKind: "server" | "draft"; + routeThreadRef: ScopedThreadRef; + draftId: DraftId | null; + draftThreadView: Thread | undefined; + activeThreadId: ThreadId | null; + activeThreadEnvironmentId: EnvironmentId | undefined; + isServerThread: boolean; + isLocalDraftThread: boolean; + isConnecting: boolean; + isSendBusy: boolean; + isPreparingWorktree: boolean; + respondingRequestIds: ApprovalRequestId[]; + respondingUserInputRequestIds: ApprovalRequestId[]; + pendingUserInputAnswersByRequestId: Record>; + pendingUserInputQuestionIndexByRequestId: Record; + planSidebarOpen: boolean; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + lockedProvider: ProviderKind | null; + resolvedTheme: "light" | "dark"; + gitCwd: string | null; + promptRef: React.MutableRefObject; + composerImagesRef: React.MutableRefObject; + composerTerminalContextsRef: React.MutableRefObject; + shouldAutoScrollRef: React.MutableRefObject; + scheduleStickToBottom: () => void; + onSend: (e?: { preventDefault: () => void }) => void; + onInterrupt: () => void; + onImplementPlanInNewThread: () => void; + onRespondToApproval: ( + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ) => Promise; + onRespondToUserInput: ( + requestId: ApprovalRequestId, + answers: Record, + ) => Promise; + onProviderModelSelect: (provider: ProviderKind, model: string) => void; + toggleInteractionMode: () => void; + handleRuntimeModeChange: (nextRuntimeMode: RuntimeMode) => void; + handleInteractionModeChange: (nextInteractionMode: ProviderInteractionMode) => void; + togglePlanSidebar: () => void; + focusComposer: () => void; + scheduleComposerFocus: () => void; + setThreadError: (targetThreadId: ThreadId | null, error: string | null) => void; + onExpandImage: (preview: ExpandedImagePreview) => void; + setPendingUserInputQuestionIndexByRequestId: React.Dispatch< + React.SetStateAction> + >; + setPendingUserInputAnswersByRequestId: React.Dispatch< + React.SetStateAction>> + >; +} + +const ChatComposerContainer = memo(function ChatComposerContainer( + props: ChatComposerContainerProps, +) { + const { + composerRef, + composerDraftTarget, + environmentId, + routeKind, + routeThreadRef, + draftId, + draftThreadView, + activeThreadId, + activeThreadEnvironmentId, + isServerThread, + isLocalDraftThread, + isConnecting, + isSendBusy, + isPreparingWorktree, + respondingRequestIds, + respondingUserInputRequestIds, + pendingUserInputAnswersByRequestId, + pendingUserInputQuestionIndexByRequestId, + planSidebarOpen, + runtimeMode, + interactionMode, + lockedProvider, + resolvedTheme, + gitCwd, + promptRef, + composerImagesRef, + composerTerminalContextsRef, + shouldAutoScrollRef, + scheduleStickToBottom, + onSend, + onInterrupt, + onImplementPlanInNewThread, + onRespondToApproval, + onRespondToUserInput, + onProviderModelSelect, + toggleInteractionMode, + handleRuntimeModeChange, + handleInteractionModeChange, + togglePlanSidebar, + focusComposer, + scheduleComposerFocus, + setThreadError, + onExpandImage, + setPendingUserInputQuestionIndexByRequestId, + setPendingUserInputAnswersByRequestId, + } = props; + const serverThreadView = useStore( + useMemo( + () => createThreadStaticShellSelectorByRef(routeKind === "server" ? routeThreadRef : null), + [routeKind, routeThreadRef], + ), ); - const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); - const composerDraftTarget: ScopedThreadRef | DraftId = - routeKind === "server" ? routeThreadRef : props.draftId; - const serverThread = useStore( + const serverComposerSnapshot = useStore( useMemo( - () => createThreadSelectorByRef(routeKind === "server" ? routeThreadRef : null), + () => + createThreadComposerSnapshotSelectorByRef(routeKind === "server" ? routeThreadRef : null), [routeKind, routeThreadRef], ), ); - const setStoreThreadError = useStore((store) => store.setError); - const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const setThreadChangedFilesExpanded = useUiStateStore( - (store) => store.setThreadChangedFilesExpanded, + const conversationThread = routeKind === "server" ? serverThreadView : draftThreadView; + const conversationProjectId = conversationThread?.projectId; + const conversationEnvironmentId = conversationThread?.environmentId; + const providerStatuses = useServerProviders() as ServerProvider[]; + const settings = useSettings(); + const activeProjectRef = useMemo( + () => + conversationEnvironmentId && conversationProjectId + ? scopeProjectRef(conversationEnvironmentId, conversationProjectId) + : null, + [conversationEnvironmentId, conversationProjectId], ); - const activeThreadLastVisitedAt = useUiStateStore((store) => - routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, + const activeProject = useStore( + useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), ); - const changedFilesExpandedByTurnId = useUiStateStore((store) => + const activeProjectDefaultModelSelection = activeProject?.defaultModelSelection; + const activeThreadSession = routeKind === "server" - ? (store.threadChangedFilesExpandedById[routeThreadKey] ?? - EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID) - : EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID, - ); - const settings = useSettings(); - const setStickyComposerModelSelection = useComposerDraftStore( - (store) => store.setStickyModelSelection, + ? (serverComposerSnapshot?.session ?? null) + : (draftThreadView?.session ?? null); + const activeLatestTurn = + routeKind === "server" + ? (serverComposerSnapshot?.latestTurn ?? null) + : (draftThreadView?.latestTurn ?? null); + const latestTurnSettled = + routeKind === "server" + ? (serverComposerSnapshot?.latestTurnSettled ?? false) + : isLatestTurnSettled(activeLatestTurn, activeThreadSession); + const threadPlanCatalog = useThreadPlanCatalog( + useMemo(() => { + const threadIds: ThreadId[] = []; + if (conversationThread?.id) { + threadIds.push(conversationThread.id); + } + const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; + if (sourceThreadId && sourceThreadId !== conversationThread?.id) { + threadIds.push(sourceThreadId); + } + return threadIds; + }, [activeLatestTurn?.sourceProposedPlan?.threadId, conversationThread?.id]), ); - const timestampFormat = settings.timestampFormat; - const navigate = useNavigate(); - const rawSearch = useSearch({ - strict: false, - select: (params) => parseDiffRouteSearch(params), - }); - const { resolvedTheme } = useTheme(); - // Granular store selectors — avoid subscribing to prompt changes. - const composerRuntimeMode = useComposerDraftStore( - (store) => store.getComposerDraft(composerDraftTarget)?.runtimeMode ?? null, + const phase = + routeKind === "server" + ? (serverComposerSnapshot?.phase ?? derivePhase(null)) + : derivePhase(draftThreadView?.session ?? null); + const pendingApprovals = + routeKind === "server" + ? (serverComposerSnapshot?.pendingApprovals ?? []) + : derivePendingApprovals(draftThreadView?.activities ?? EMPTY_ACTIVITIES); + const pendingUserInputs = + routeKind === "server" + ? (serverComposerSnapshot?.pendingUserInputs ?? []) + : derivePendingUserInputs(draftThreadView?.activities ?? EMPTY_ACTIVITIES); + const activePendingUserInput = pendingUserInputs[0] ?? null; + const activePendingDraftAnswers = useMemo( + () => + activePendingUserInput + ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? + EMPTY_PENDING_USER_INPUT_ANSWERS) + : EMPTY_PENDING_USER_INPUT_ANSWERS, + [activePendingUserInput, pendingUserInputAnswersByRequestId], ); - const composerInteractionMode = useComposerDraftStore( - (store) => store.getComposerDraft(composerDraftTarget)?.interactionMode ?? null, + const activePendingQuestionIndex = activePendingUserInput + ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) + : 0; + const activePendingProgress = useMemo( + () => + activePendingUserInput + ? derivePendingUserInputProgress( + activePendingUserInput.questions, + activePendingDraftAnswers, + activePendingQuestionIndex, + ) + : null, + [activePendingDraftAnswers, activePendingQuestionIndex, activePendingUserInput], ); - const composerActiveProvider = useComposerDraftStore( - (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, + const activePendingResolvedAnswers = useMemo( + () => + activePendingUserInput + ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers) + : null, + [activePendingDraftAnswers, activePendingUserInput], ); - const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); - const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); - const setComposerDraftTerminalContexts = useComposerDraftStore( - (store) => store.setTerminalContexts, + const activeProposedPlan = + routeKind === "server" + ? (serverComposerSnapshot?.activeProposedPlan ?? null) + : latestTurnSettled + ? findLatestProposedPlan( + draftThreadView?.proposedPlans ?? EMPTY_PROPOSED_PLANS, + activeLatestTurn?.turnId ?? null, + ) + : null; + const sidebarProposedPlan = useMemo( + () => + findSidebarProposedPlan({ + threads: threadPlanCatalog, + latestTurn: activeLatestTurn, + latestTurnSettled, + threadId: conversationThread?.id ?? null, + }), + [activeLatestTurn, conversationThread?.id, latestTurnSettled, threadPlanCatalog], + ); + const activePlan = + routeKind === "server" + ? (serverComposerSnapshot?.activePlan ?? null) + : deriveActivePlanState( + draftThreadView?.activities ?? EMPTY_ACTIVITIES, + activeLatestTurn?.turnId ?? undefined, + ); + const showPlanFollowUpPrompt = + pendingUserInputs.length === 0 && + interactionMode === "plan" && + latestTurnSettled && + hasActionableProposedPlan(activeProposedPlan); + const activePendingApproval = pendingApprovals[0] ?? null; + + const setActivePendingUserInputQuestionIndex = useCallback( + (nextQuestionIndex: number) => { + if (!activePendingUserInput) { + return; + } + setPendingUserInputQuestionIndexByRequestId((existing) => ({ + ...existing, + [activePendingUserInput.requestId]: nextQuestionIndex, + })); + }, + [activePendingUserInput, setPendingUserInputQuestionIndexByRequestId], + ); + + const onSelectActivePendingUserInputOption = useCallback( + (questionId: string, optionLabel: string) => { + if (!activePendingUserInput) { + return; + } + setPendingUserInputAnswersByRequestId((existing) => { + const question = + (activePendingProgress?.activeQuestion?.id === questionId + ? activePendingProgress.activeQuestion + : undefined) ?? + activePendingUserInput.questions.find((entry) => entry.id === questionId); + if (!question) { + return existing; + } + + return { + ...existing, + [activePendingUserInput.requestId]: { + ...existing[activePendingUserInput.requestId], + [questionId]: togglePendingUserInputOptionSelection( + question, + existing[activePendingUserInput.requestId]?.[questionId], + optionLabel, + ), + }, + }; + }); + promptRef.current = ""; + composerRef.current?.resetCursorState({ cursor: 0 }); + }, + [ + activePendingProgress?.activeQuestion, + activePendingUserInput, + composerRef, + promptRef, + setPendingUserInputAnswersByRequestId, + ], + ); + + const onChangeActivePendingUserInputCustomAnswer = useCallback( + ( + questionId: string, + value: string, + nextCursor: number, + expandedCursor: number, + _cursorAdjacentToMention: boolean, + ) => { + if (!activePendingUserInput) { + return; + } + promptRef.current = value; + setPendingUserInputAnswersByRequestId((existing) => ({ + ...existing, + [activePendingUserInput.requestId]: { + ...existing[activePendingUserInput.requestId], + [questionId]: setPendingUserInputCustomAnswer( + existing[activePendingUserInput.requestId]?.[questionId], + value, + ), + }, + })); + const snapshot = composerRef.current?.readSnapshot(); + if ( + snapshot?.value !== value || + snapshot.cursor !== nextCursor || + snapshot.expandedCursor !== expandedCursor + ) { + composerRef.current?.focusAt(nextCursor); + } + }, + [activePendingUserInput, composerRef, promptRef, setPendingUserInputAnswersByRequestId], + ); + + const onAdvanceActivePendingUserInput = useCallback(() => { + if (!activePendingUserInput || !activePendingProgress) { + return; + } + if (activePendingProgress.isLastQuestion) { + if (activePendingResolvedAnswers) { + void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); + } + return; + } + setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); + }, [ + activePendingProgress, + activePendingResolvedAnswers, + activePendingUserInput, + onRespondToUserInput, + setActivePendingUserInputQuestionIndex, + ]); + + const onPreviousActivePendingUserInputQuestion = useCallback(() => { + if (!activePendingProgress) { + return; + } + setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); + }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); + + return ( + + ); +}); + +interface PlanSidebarContainerProps { + routeKind: "server" | "draft"; + routeThreadRef: ScopedThreadRef; + draftThreadView: Thread | undefined; + environmentId: EnvironmentId; + markdownCwd: string | undefined; + workspaceRoot: string | undefined; + timestampFormat: UnifiedSettings["timestampFormat"]; + open: boolean; + setOpen: React.Dispatch>; + planSidebarDismissedForTurnRef: React.MutableRefObject; + currentTurnKeyRef: React.MutableRefObject; +} + +const PlanSidebarContainer = memo(function PlanSidebarContainer(props: PlanSidebarContainerProps) { + const { + routeKind, + routeThreadRef, + draftThreadView, + environmentId, + markdownCwd, + workspaceRoot, + timestampFormat, + open, + setOpen, + planSidebarDismissedForTurnRef, + currentTurnKeyRef, + } = props; + const serverThreadView = useStore( + useMemo( + () => createThreadStaticShellSelectorByRef(routeKind === "server" ? routeThreadRef : null), + [routeKind, routeThreadRef], + ), + ); + const serverComposerSnapshot = useStore( + useMemo( + () => + createThreadComposerSnapshotSelectorByRef(routeKind === "server" ? routeThreadRef : null), + [routeKind, routeThreadRef], + ), + ); + const activeThreadId = + routeKind === "server" ? (serverThreadView?.id ?? null) : (draftThreadView?.id ?? null); + const activeLatestTurn = + routeKind === "server" + ? (serverComposerSnapshot?.latestTurn ?? null) + : (draftThreadView?.latestTurn ?? null); + const activeThreadSession = + routeKind === "server" + ? (serverComposerSnapshot?.session ?? null) + : (draftThreadView?.session ?? null); + const latestTurnSettled = + routeKind === "server" + ? (serverComposerSnapshot?.latestTurnSettled ?? false) + : isLatestTurnSettled(activeLatestTurn, activeThreadSession); + const threadPlanCatalog = useThreadPlanCatalog( + useMemo(() => { + const threadIds: ThreadId[] = []; + if (activeThreadId) { + threadIds.push(activeThreadId); + } + const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; + if (sourceThreadId && sourceThreadId !== activeThreadId) { + threadIds.push(sourceThreadId); + } + return threadIds; + }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThreadId]), + ); + const activePlan = + routeKind === "server" + ? (serverComposerSnapshot?.activePlan ?? null) + : deriveActivePlanState( + draftThreadView?.activities ?? EMPTY_ACTIVITIES, + activeLatestTurn?.turnId ?? undefined, + ); + const sidebarProposedPlan = useMemo( + () => + findSidebarProposedPlan({ + threads: threadPlanCatalog, + latestTurn: activeLatestTurn, + latestTurnSettled, + threadId: activeThreadId, + }), + [activeLatestTurn, activeThreadId, latestTurnSettled, threadPlanCatalog], + ); + const currentTurnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; + + useEffect(() => { + currentTurnKeyRef.current = currentTurnKey; + }, [currentTurnKey, currentTurnKeyRef]); + + if (!open) { + return null; + } + + return ( + { + setOpen(false); + if (currentTurnKey) { + planSidebarDismissedForTurnRef.current = currentTurnKey; + } + }} + /> + ); +}); + +function ChatView(props: ChatViewProps) { + const { environmentId, threadId, routeKind, onDiffPanelOpen } = props; + const draftId = routeKind === "draft" ? props.draftId : null; + const routeThreadRef = useMemo( + () => scopeThreadRef(environmentId, threadId), + [environmentId, threadId], + ); + const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); + const composerDraftTarget: ScopedThreadRef | DraftId = + routeKind === "server" ? routeThreadRef : props.draftId; + const serverThreadRuntimeSelector = useMemo( + () => createThreadRuntimeSnapshotSelectorByRef(routeKind === "server" ? routeThreadRef : null), + [routeKind, routeThreadRef], + ); + const serverThreadPendingSelector = useMemo( + () => createThreadPendingSnapshotSelectorByRef(routeKind === "server" ? routeThreadRef : null), + [routeKind, routeThreadRef], + ); + const serverThreadActivitiesSelector = useMemo( + () => createThreadActivitiesSelectorByRef(routeKind === "server" ? routeThreadRef : null), + [routeKind, routeThreadRef], + ); + const serverThreadProposedPlansSelector = useMemo( + () => createThreadProposedPlansSelectorByRef(routeKind === "server" ? routeThreadRef : null), + [routeKind, routeThreadRef], + ); + const serverThreadShell = useStore( + useMemo( + () => createThreadStaticShellSelectorByRef(routeKind === "server" ? routeThreadRef : null), + [routeKind, routeThreadRef], + ), + ); + const serverMessageIds = useStore( + useMemo( + () => createThreadMessageIdsSelectorByRef(routeKind === "server" ? routeThreadRef : null), + [routeKind, routeThreadRef], + ), + ); + const setStoreThreadError = useStore((store) => store.setError); + const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); + const activeThreadLastVisitedAt = useUiStateStore((store) => + routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, + ); + const settings = useSettings(); + const setStickyComposerModelSelection = useComposerDraftStore( + (store) => store.setStickyModelSelection, + ); + const timestampFormat = settings.timestampFormat; + const navigate = useNavigate(); + const rawSearch = useSearch({ + strict: false, + select: (params) => parseDiffRouteSearch(params), + }); + const { resolvedTheme } = useTheme(); + // Granular store selectors — avoid subscribing to prompt changes. + const composerRuntimeMode = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.runtimeMode ?? null, + ); + const composerInteractionMode = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.interactionMode ?? null, + ); + const composerActiveProvider = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, + ); + const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); + const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); + const setComposerDraftTerminalContexts = useComposerDraftStore( + (store) => store.setTerminalContexts, ); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); @@ -654,11 +1712,7 @@ export default function ChatView(props: ChatViewProps) { const composerTerminalContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; - const [showScrollToBottom, setShowScrollToBottom] = useState(false); - const [expandedImage, setExpandedImage] = useState(null); - const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); - const optimisticUserMessagesRef = useRef(optimisticUserMessages); - optimisticUserMessagesRef.current = optimisticUserMessages; + const messagesPaneRef = useRef(null); const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< Record >({}); @@ -673,49 +1727,27 @@ export default function ChatView(props: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. const planSidebarOpenOnNextThreadRef = useRef(false); - const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); const [terminalLaunchContext, setTerminalLaunchContext] = useState( null, ); - const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< - Record - >({}); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, LastInvokedScriptByProjectSchema, ); - const messagesScrollRef = useRef(null); - const [messagesScrollElement, setMessagesScrollElement] = useState(null); const shouldAutoScrollRef = useRef(true); - const lastKnownScrollTopRef = useRef(0); - const isPointerScrollActiveRef = useRef(false); - const lastTouchClientYRef = useRef(null); - const pendingUserScrollUpIntentRef = useRef(false); - const pendingAutoScrollFrameRef = useRef(null); - const pendingInteractionAnchorRef = useRef<{ - element: HTMLElement; - top: number; - } | null>(null); - const pendingInteractionAnchorFrameRef = useRef(null); - const attachmentPreviewHandoffByMessageIdRef = useRef>({}); - const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const terminalOpenByThreadRef = useRef>({}); - const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { - messagesScrollRef.current = element; - setMessagesScrollElement(element); - }, []); + const planSidebarCurrentTurnKeyRef = useRef(null); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef), @@ -734,7 +1766,7 @@ export default function ChatView(props: ChatViewProps) { const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); const serverThreadKeys = useStore( useShallow((state) => - selectThreadsAcrossEnvironments(state).map((thread) => + selectThreadShellsAcrossEnvironments(state).map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ), ), @@ -769,6 +1801,7 @@ export default function ChatView(props: ChatViewProps) { const fallbackDraftProject = useStore( useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]), ); + const serverThread = serverThreadShell; const localDraftError = routeKind === "server" && serverThread ? null @@ -788,8 +1821,27 @@ export default function ChatView(props: ChatViewProps) { : undefined, [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], ); - const isServerThread = routeKind === "server" && serverThread !== undefined; - const activeThread = isServerThread ? serverThread : localDraftThread; + const isServerThread = routeKind === "server" && serverThreadShell !== undefined; + const activeThread = isServerThread ? serverThreadShell : localDraftThread; + const getCurrentServerRuntimeSnapshot = useCallback( + () => serverThreadRuntimeSelector(useStore.getState()) ?? null, + [serverThreadRuntimeSelector], + ); + const getCurrentServerPendingSnapshot = useCallback( + () => serverThreadPendingSelector(useStore.getState()), + [serverThreadPendingSelector], + ); + const getCurrentServerActivities = useCallback( + () => serverThreadActivitiesSelector(useStore.getState()), + [serverThreadActivitiesSelector], + ); + const getCurrentServerProposedPlans = useCallback( + () => serverThreadProposedPlansSelector(useStore.getState()), + [serverThreadProposedPlansSelector], + ); + const activeMessageCount = isServerThread + ? serverMessageIds.length + : (localDraftThread?.messages.length ?? 0); const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; @@ -806,20 +1858,6 @@ export default function ChatView(props: ChatViewProps) { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); - const activeLatestTurn = activeThread?.latestTurn ?? null; - const threadPlanCatalog = useThreadPlanCatalog( - useMemo(() => { - const threadIds: ThreadId[] = []; - if (activeThread?.id) { - threadIds.push(activeThread.id); - } - const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; - if (sourceThreadId && sourceThreadId !== activeThread?.id) { - threadIds.push(sourceThreadId); - } - return threadIds; - }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), - ); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ @@ -835,13 +1873,28 @@ export default function ChatView(props: ChatViewProps) { : nextThreadIds; }); }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]); - const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; const activeProject = useStore( useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), ); + const getCurrentConversationRuntimeState = useCallback(() => { + if (isServerThread) { + const runtimeSnapshot = getCurrentServerRuntimeSnapshot(); + return { + latestTurn: runtimeSnapshot?.latestTurn ?? null, + session: runtimeSnapshot?.session ?? null, + phase: runtimeSnapshot?.phase ?? derivePhase(null), + }; + } + + return { + latestTurn: localDraftThread?.latestTurn ?? null, + session: localDraftThread?.session ?? null, + phase: derivePhase(localDraftThread?.session ?? null), + }; + }, [getCurrentServerRuntimeSnapshot, isServerThread, localDraftThread]); // Compute the list of environments this logical project spans, used to // drive the environment picker in BranchToolbar. @@ -995,30 +2048,44 @@ export default function ChatView(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); - const handleSetChangedFilesExpanded = useCallback( - (turnId: TurnId, expanded: boolean) => { - if (routeKind !== "server") { + useEffect(() => { + if (!isServerThread || !serverThread?.id) { + return; + } + + const maybeMarkVisited = () => { + const runtimeSnapshot = getCurrentServerRuntimeSnapshot(); + const latestTurn = runtimeSnapshot?.latestTurn ?? null; + const session = runtimeSnapshot?.session ?? null; + if (!isLatestTurnSettled(latestTurn, session)) { + return; + } + if (!latestTurn?.completedAt) { + return; + } + const turnCompletedAt = Date.parse(latestTurn.completedAt); + if (Number.isNaN(turnCompletedAt)) { + return; + } + const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; + if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) { return; } - setThreadChangedFilesExpanded(routeThreadKey, turnId, expanded); - }, - [routeKind, routeThreadKey, setThreadChangedFilesExpanded], - ); - useEffect(() => { - if (!serverThread?.id) return; - if (!latestTurnSettled) return; - if (!activeLatestTurn?.completedAt) return; - const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); - if (Number.isNaN(turnCompletedAt)) return; - const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; - - markThreadVisited(scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id))); + markThreadVisited( + scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), + ); + }; + + maybeMarkVisited(); + const unsubscribe = useStore.subscribe(maybeMarkVisited); + return () => { + unsubscribe(); + }; }, [ - activeLatestTurn?.completedAt, activeThreadLastVisitedAt, - latestTurnSettled, + getCurrentServerRuntimeSnapshot, + isServerThread, markThreadVisited, serverThread?.environmentId, serverThread?.id, @@ -1027,8 +2094,15 @@ export default function ChatView(props: ChatViewProps) { const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null; + const currentConversationRuntimeState = getCurrentConversationRuntimeState(); const lockedProvider = deriveLockedProvider({ - thread: activeThread, + thread: activeThread + ? { + latestTurn: currentConversationRuntimeState.latestTurn, + session: currentConversationRuntimeState.session, + messageCount: activeMessageCount, + } + : null, selectedProvider: selectedProviderByThreadId, threadProvider, }); @@ -1044,373 +2118,61 @@ export default function ChatView(props: ChatViewProps) { ? primaryServerConfig : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; - const unlockedSelectedProvider = resolveSelectableProvider( - providerStatuses, - selectedProviderByThreadId ?? threadProvider ?? "codex", - ); - const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; - const phase = derivePhase(activeThread?.session ?? null); - const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; - const workLogEntries = useMemo( - () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), - [activeLatestTurn?.turnId, threadActivities], - ); - const latestTurnHasToolActivity = useMemo( - () => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId), - [activeLatestTurn?.turnId, threadActivities], - ); - const pendingApprovals = useMemo( - () => derivePendingApprovals(threadActivities), - [threadActivities], - ); - const pendingUserInputs = useMemo( - () => derivePendingUserInputs(threadActivities), - [threadActivities], - ); - const activePendingUserInput = pendingUserInputs[0] ?? null; - const activePendingDraftAnswers = useMemo( - () => - activePendingUserInput - ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? - EMPTY_PENDING_USER_INPUT_ANSWERS) - : EMPTY_PENDING_USER_INPUT_ANSWERS, - [activePendingUserInput, pendingUserInputAnswersByRequestId], - ); - const activePendingQuestionIndex = activePendingUserInput - ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) - : 0; - const activePendingProgress = useMemo( - () => - activePendingUserInput - ? derivePendingUserInputProgress( - activePendingUserInput.questions, - activePendingDraftAnswers, - activePendingQuestionIndex, - ) - : null, - [activePendingDraftAnswers, activePendingQuestionIndex, activePendingUserInput], - ); - const activePendingResolvedAnswers = useMemo( - () => - activePendingUserInput - ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers) - : null, - [activePendingDraftAnswers, activePendingUserInput], - ); - const activePendingIsResponding = activePendingUserInput - ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId) - : false; - const activeProposedPlan = useMemo(() => { - if (!latestTurnSettled) { - return null; - } - return findLatestProposedPlan( - activeThread?.proposedPlans ?? [], - activeLatestTurn?.turnId ?? null, + const selectedProvider: ProviderKind = + lockedProvider ?? + resolveSelectableProvider( + providerStatuses, + selectedProviderByThreadId ?? threadProvider ?? "codex", ); - }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); - const sidebarProposedPlan = useMemo( - () => - findSidebarProposedPlan({ - threads: threadPlanCatalog, - latestTurn: activeLatestTurn, - latestTurnSettled, - threadId: activeThread?.id ?? null, - }), - [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], - ); - const activePlan = useMemo( - () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), - [activeLatestTurn?.turnId, threadActivities], - ); - const showPlanFollowUpPrompt = - pendingUserInputs.length === 0 && - interactionMode === "plan" && - latestTurnSettled && - hasActionableProposedPlan(activeProposedPlan); - const activePendingApproval = pendingApprovals[0] ?? null; const { beginLocalDispatch, resetLocalDispatch, localDispatchStartedAt, - isPreparingWorktree, - isSendBusy, - } = useLocalDispatchState({ - activeThread, - activeLatestTurn, - phase, - activePendingApproval: activePendingApproval?.requestId ?? null, - activePendingUserInput: activePendingUserInput?.requestId ?? null, - threadError: activeThread?.error, - }); - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; - const nowIso = new Date(nowTick).toISOString(); - const activeWorkStartedAt = deriveActiveWorkStartedAt( - activeLatestTurn, - activeThread?.session ?? null, - localDispatchStartedAt, - ); - useEffect(() => { - attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; - }, [attachmentPreviewHandoffByMessageId]); - const clearAttachmentPreviewHandoff = useCallback( - (messageId: MessageId, previewUrls?: ReadonlyArray) => { - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - const currentPreviewUrls = - previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - setAttachmentPreviewHandoffByMessageId((existing) => { - if (!(messageId in existing)) { - return existing; - } - const next = { ...existing }; - delete next[messageId]; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - for (const previewUrl of currentPreviewUrls) { - revokeBlobPreviewUrl(previewUrl); - } - }, - [], - ); - const clearAttachmentPreviewHandoffs = useCallback(() => { - attachmentPreviewPromotionInFlightByMessageIdRef.current = {}; - for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { - for (const previewUrl of previewUrls) { - revokeBlobPreviewUrl(previewUrl); - } - } - attachmentPreviewHandoffByMessageIdRef.current = {}; - setAttachmentPreviewHandoffByMessageId({}); - }, []); - useEffect(() => { - return () => { - clearAttachmentPreviewHandoffs(); - for (const message of optimisticUserMessagesRef.current) { - revokeUserMessagePreviewUrls(message); - } - }; - }, [clearAttachmentPreviewHandoffs]); - const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { - if (previewUrls.length === 0) return; - - const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - for (const previewUrl of previousPreviewUrls) { - if (!previewUrls.includes(previewUrl)) { - revokeBlobPreviewUrl(previewUrl); - } - } - setAttachmentPreviewHandoffByMessageId((existing) => { - const next = { - ...existing, - [messageId]: previewUrls, - }; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - }, []); - const serverMessages = activeThread?.messages; - useEffect(() => { - if (typeof Image === "undefined" || !serverMessages || serverMessages.length === 0) { - return; - } - - const cleanups: Array<() => void> = []; - - for (const [messageId, handoffPreviewUrls] of Object.entries( - attachmentPreviewHandoffByMessageId, - )) { - if (attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]) { - continue; - } - - const serverMessage = serverMessages.find( - (message) => message.id === messageId && message.role === "user", - ); - if (!serverMessage?.attachments || serverMessage.attachments.length === 0) { - continue; - } - - const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => - attachment.type === "image" && attachment.previewUrl ? [attachment.previewUrl] : [], - ); - if ( - serverPreviewUrls.length === 0 || - serverPreviewUrls.length !== handoffPreviewUrls.length || - serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:")) - ) { - continue; - } - - attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId] = true; - - let cancelled = false; - const imageInstances: HTMLImageElement[] = []; - - const preloadServerPreviews = Promise.all( - serverPreviewUrls.map( - (previewUrl) => - new Promise((resolve, reject) => { - const image = new Image(); - imageInstances.push(image); - const handleLoad = () => resolve(); - const handleError = () => - reject(new Error(`Failed to load server preview for ${messageId}.`)); - image.addEventListener("load", handleLoad, { once: true }); - image.addEventListener("error", handleError, { once: true }); - image.src = previewUrl; - }), - ), - ); - - void preloadServerPreviews - .then(() => { - if (cancelled) { - return; - } - clearAttachmentPreviewHandoff(messageId as MessageId, handoffPreviewUrls); - }) - .catch(() => { - if (!cancelled) { - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - } - }); - - cleanups.push(() => { - cancelled = true; - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - for (const image of imageInstances) { - image.src = ""; - } - }); - } - - return () => { - for (const cleanup of cleanups) { - cleanup(); - } - }; - }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, serverMessages]); - const timelineMessages = useMemo(() => { - const messages = serverMessages ?? []; - const serverMessagesWithPreviewHandoff = - Object.keys(attachmentPreviewHandoffByMessageId).length === 0 - ? messages - : // Spread only fires for the few messages that actually changed; - // unchanged ones early-return their original reference. - // In-place mutation would break React's immutable state contract. - // oxlint-disable-next-line no-map-spread - messages.map((message) => { - if ( - message.role !== "user" || - !message.attachments || - message.attachments.length === 0 - ) { - return message; - } - const handoffPreviewUrls = attachmentPreviewHandoffByMessageId[message.id]; - if (!handoffPreviewUrls || handoffPreviewUrls.length === 0) { - return message; - } - - let changed = false; - let imageIndex = 0; - const attachments = message.attachments.map((attachment) => { - if (attachment.type !== "image") { - return attachment; - } - const handoffPreviewUrl = handoffPreviewUrls[imageIndex]; - imageIndex += 1; - if (!handoffPreviewUrl || attachment.previewUrl === handoffPreviewUrl) { - return attachment; - } - changed = true; - return { - ...attachment, - previewUrl: handoffPreviewUrl, - }; - }); - - return changed ? { ...message, attachments } : message; - }); - - if (optimisticUserMessages.length === 0) { - return serverMessagesWithPreviewHandoff; - } - const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); - const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); - if (pendingMessages.length === 0) { - return serverMessagesWithPreviewHandoff; - } - return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); - const timelineEntries = useMemo( - () => - deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), - [activeThread?.proposedPlans, timelineMessages, workLogEntries], - ); - const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = - useTurnDiffSummaries(activeThread); - const turnDiffSummaryByAssistantMessageId = useMemo(() => { - const byMessageId = new Map(); - for (const summary of turnDiffSummaries) { - if (!summary.assistantMessageId) continue; - byMessageId.set(summary.assistantMessageId, summary); - } - return byMessageId; - }, [turnDiffSummaries]); - const revertTurnCountByUserMessageId = useMemo(() => { - const byUserMessageId = new Map(); - for (let index = 0; index < timelineEntries.length; index += 1) { - const entry = timelineEntries[index]; - if (!entry || entry.kind !== "message" || entry.message.role !== "user") { - continue; - } - - for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) { - const nextEntry = timelineEntries[nextIndex]; - if (!nextEntry || nextEntry.kind !== "message") { - continue; - } - if (nextEntry.message.role === "user") { - break; - } - const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); - if (!summary) { - continue; - } - const turnCount = - summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; - if (typeof turnCount !== "number") { - break; - } - byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); - break; + isPreparingWorktree, + isSendBusy, + } = useLocalDispatchState({ + getActiveThreadSnapshot: () => { + if (isServerThread) { + const runtimeSnapshot = getCurrentServerRuntimeSnapshot(); + return runtimeSnapshot + ? { + latestTurn: runtimeSnapshot.latestTurn, + session: runtimeSnapshot.session, + } + : undefined; } - } - return byUserMessageId; - }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); - - const completionSummary = useMemo(() => { - if (!latestTurnSettled) return null; - if (!activeLatestTurn?.startedAt) return null; - if (!activeLatestTurn.completedAt) return null; - if (!latestTurnHasToolActivity) return null; + return localDraftThread + ? { + latestTurn: localDraftThread.latestTurn, + session: localDraftThread.session, + } + : undefined; + }, + getAcknowledgementSnapshot: () => { + if (isServerThread) { + const runtimeSnapshot = getCurrentServerRuntimeSnapshot(); + const pendingSnapshot = getCurrentServerPendingSnapshot(); + return { + latestTurn: runtimeSnapshot?.latestTurn ?? null, + phase: runtimeSnapshot?.phase ?? derivePhase(null), + session: runtimeSnapshot?.session ?? null, + hasPendingApproval: pendingSnapshot.pendingApprovalRequestId !== null, + hasPendingUserInput: pendingSnapshot.pendingUserInputRequestId !== null, + threadError: activeThread?.error, + }; + } - const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); - return elapsed ? `Worked for ${elapsed}` : null; - }, [ - activeLatestTurn?.completedAt, - activeLatestTurn?.startedAt, - latestTurnHasToolActivity, - latestTurnSettled, - ]); - const completionDividerBeforeEntryId = useMemo(() => { - if (!latestTurnSettled) return null; - if (!completionSummary) return null; - return deriveCompletionDividerBeforeEntryId(timelineEntries, activeLatestTurn); - }, [activeLatestTurn, completionSummary, latestTurnSettled, timelineEntries]); + return { + latestTurn: localDraftThread?.latestTurn ?? null, + phase: derivePhase(localDraftThread?.session ?? null), + session: localDraftThread?.session ?? null, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: activeThread?.error, + }; + }, + }); const gitCwd = activeProject ? projectScriptCwd({ project: { cwd: activeProject.cwd }, @@ -1494,8 +2256,9 @@ export default function ChatView(props: ChatViewProps) { const envLocked = Boolean( activeThread && - (activeThread.messages.length > 0 || - (activeThread.session !== null && activeThread.session.status !== "closed")), + (activeMessageCount > 0 || + (currentConversationRuntimeState.session !== null && + currentConversationRuntimeState.session.status !== "closed")), ); // Handle environment change for draft threads. When the user picks a @@ -1905,7 +2668,7 @@ export default function ChatView(props: ChatViewProps) { const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { if (open) { - const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; + const turnKey = planSidebarCurrentTurnKeyRef.current; if (turnKey) { planSidebarDismissedForTurnRef.current = turnKey; } @@ -1914,7 +2677,7 @@ export default function ChatView(props: ChatViewProps) { } return !open; }); - }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); + }, []); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -1970,168 +2733,14 @@ export default function ChatView(props: ChatViewProps) { [environmentId, serverThread], ); - // Auto-scroll on new messages - const messageCount = timelineMessages.length; - const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); - lastKnownScrollTopRef.current = scrollContainer.scrollTop; - shouldAutoScrollRef.current = true; - }, []); - const cancelPendingStickToBottom = useCallback(() => { - const pendingFrame = pendingAutoScrollFrameRef.current; - if (pendingFrame === null) return; - pendingAutoScrollFrameRef.current = null; - window.cancelAnimationFrame(pendingFrame); - }, []); - const cancelPendingInteractionAnchorAdjustment = useCallback(() => { - const pendingFrame = pendingInteractionAnchorFrameRef.current; - if (pendingFrame === null) return; - pendingInteractionAnchorFrameRef.current = null; - window.cancelAnimationFrame(pendingFrame); - }, []); const scheduleStickToBottom = useCallback(() => { - if (pendingAutoScrollFrameRef.current !== null) return; - pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { - pendingAutoScrollFrameRef.current = null; - scrollMessagesToBottom(); - }); - }, [scrollMessagesToBottom]); - const onMessagesClickCapture = useCallback( - (event: React.MouseEvent) => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer || !(event.target instanceof Element)) return; - - const trigger = event.target.closest( - "button, summary, [role='button'], [data-scroll-anchor-target]", - ); - if (!trigger || !scrollContainer.contains(trigger)) return; - if (trigger.closest("[data-scroll-anchor-ignore]")) return; - - pendingInteractionAnchorRef.current = { - element: trigger, - top: trigger.getBoundingClientRect().top, - }; - - cancelPendingInteractionAnchorAdjustment(); - pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame(() => { - pendingInteractionAnchorFrameRef.current = null; - const anchor = pendingInteractionAnchorRef.current; - pendingInteractionAnchorRef.current = null; - const activeScrollContainer = messagesScrollRef.current; - if (!anchor || !activeScrollContainer) return; - if (!anchor.element.isConnected || !activeScrollContainer.contains(anchor.element)) return; - - const nextTop = anchor.element.getBoundingClientRect().top; - const delta = nextTop - anchor.top; - if (Math.abs(delta) < 0.5) return; - - activeScrollContainer.scrollTop += delta; - lastKnownScrollTopRef.current = activeScrollContainer.scrollTop; - }); - }, - [cancelPendingInteractionAnchorAdjustment], - ); - const forceStickToBottom = useCallback(() => { - cancelPendingStickToBottom(); - scrollMessagesToBottom(); - scheduleStickToBottom(); - }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); - const onMessagesScroll = useCallback(() => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - const currentScrollTop = scrollContainer.scrollTop; - const isNearBottom = isScrollContainerNearBottom(scrollContainer); - - if (!shouldAutoScrollRef.current && isNearBottom) { - shouldAutoScrollRef.current = true; - pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp && !isNearBottom) { - shouldAutoScrollRef.current = false; - } - pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp && !isNearBottom) { - shouldAutoScrollRef.current = false; - } - } else if (shouldAutoScrollRef.current && !isNearBottom) { - // Catch-all for keyboard/assistive scroll interactions. - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { - shouldAutoScrollRef.current = false; - } - } - - setShowScrollToBottom(!shouldAutoScrollRef.current); - lastKnownScrollTopRef.current = currentScrollTop; - }, []); - const onMessagesWheel = useCallback((event: React.WheelEvent) => { - if (event.deltaY < 0) { - pendingUserScrollUpIntentRef.current = true; - } - }, []); - const onMessagesPointerDown = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = true; - }, []); - const onMessagesPointerUp = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, []); - const onMessagesPointerCancel = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, []); - const onMessagesTouchStart = useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - lastTouchClientYRef.current = touch.clientY; + messagesPaneRef.current?.scheduleStickToBottom(); }, []); - const onMessagesTouchMove = useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - const previousTouchY = lastTouchClientYRef.current; - if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { - pendingUserScrollUpIntentRef.current = true; - } - lastTouchClientYRef.current = touch.clientY; - }, []); - const onMessagesTouchEnd = useCallback((_event: React.TouchEvent) => { - lastTouchClientYRef.current = null; - }, []); - useEffect(() => { - return () => { - cancelPendingStickToBottom(); - cancelPendingInteractionAnchorAdjustment(); - }; - }, [cancelPendingInteractionAnchorAdjustment, cancelPendingStickToBottom]); - useLayoutEffect(() => { - if (!activeThread?.id) return; + const forceStickToBottom = useCallback(() => { shouldAutoScrollRef.current = true; - scheduleStickToBottom(); - const timeout = window.setTimeout(() => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - if (isScrollContainerNearBottom(scrollContainer)) return; - scheduleStickToBottom(); - }, 96); - return () => { - window.clearTimeout(timeout); - }; - }, [activeThread?.id, scheduleStickToBottom]); - useEffect(() => { - if (!shouldAutoScrollRef.current) return; - scheduleStickToBottom(); - }, [messageCount, scheduleStickToBottom]); - useEffect(() => { - if (phase !== "running") return; - if (!shouldAutoScrollRef.current) return; - scheduleStickToBottom(); - }, [phase, scheduleStickToBottom, timelineEntries]); - + messagesPaneRef.current?.forceStickToBottom(); + }, []); useEffect(() => { - setExpandedWorkGroups({}); setPullRequestDialogState(null); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; @@ -2157,48 +2766,9 @@ export default function ChatView(props: ChatViewProps) { }, [activeThread?.id, focusComposer, terminalState.terminalOpen]); useEffect(() => { - if (!activeThread?.id) return; - if (activeThread.messages.length === 0) { - return; - } - const serverIds = new Set(activeThread.messages.map((message) => message.id)); - const removedMessages = optimisticUserMessages.filter((message) => serverIds.has(message.id)); - if (removedMessages.length === 0) { - return; - } - const timer = window.setTimeout(() => { - setOptimisticUserMessages((existing) => - existing.filter((message) => !serverIds.has(message.id)), - ); - }, 0); - for (const removedMessage of removedMessages) { - const previewUrls = collectUserMessageBlobPreviewUrls(removedMessage); - if (previewUrls.length > 0) { - handoffAttachmentPreviews(removedMessage.id, previewUrls); - continue; - } - revokeUserMessagePreviewUrls(removedMessage); - } - return () => { - window.clearTimeout(timer); - }; - }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]); - - useEffect(() => { - setOptimisticUserMessages((existing) => { - for (const message of existing) { - revokeUserMessagePreviewUrls(message); - } - return []; - }); resetLocalDispatch(); - setExpandedImage(null); }, [draftId, resetLocalDispatch, threadId]); - const closeExpandedImage = useCallback(() => { - setExpandedImage(null); - }, []); - const activeWorktreePath = activeThread?.worktreePath ?? null; const envMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ activeWorktreePath, @@ -2290,16 +2860,6 @@ export default function ChatView(props: ChatViewProps) { terminalState.terminalOpen, ]); - useEffect(() => { - if (phase !== "running") return; - const timer = window.setInterval(() => { - setNowTick(Date.now()); - }, 1000); - return () => { - window.clearInterval(timer); - }; - }, [phase]); - useEffect(() => { if (!activeThreadKey) return; const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; @@ -2410,7 +2970,8 @@ export default function ChatView(props: ChatViewProps) { const localApi = readLocalApi(); if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; - if (phase === "running" || isSendBusy || isConnecting) { + const currentPhase = getCurrentConversationRuntimeState().phase; + if (currentPhase === "running" || isSendBusy || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; } @@ -2446,20 +3007,38 @@ export default function ChatView(props: ChatViewProps) { [ activeThread, environmentId, + getCurrentConversationRuntimeState, isConnecting, isRevertingCheckpoint, isSendBusy, - phase, setThreadError, ], ); - const onSend = async (e?: { preventDefault: () => void }) => { + const handleSend = useEffectEvent(async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readEnvironmentApi(environmentId); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; + const currentConversation = resolveCurrentConversationDispatchState(); + const activePendingUserInput = currentConversation.activePendingUserInput; + const activePendingProgress = currentConversation.activePendingProgress; if (activePendingProgress) { - onAdvanceActivePendingUserInput(); + if (!activePendingUserInput) { + return; + } + if (activePendingProgress.isLastQuestion) { + if (currentConversation.activePendingResolvedAnswers) { + void onRespondToUserInput( + activePendingUserInput.requestId, + currentConversation.activePendingResolvedAnswers, + ); + } + } else { + setPendingUserInputQuestionIndexByRequestId((existing) => ({ + ...existing, + [activePendingUserInput.requestId]: activePendingProgress.questionIndex + 1, + })); + } return; } const sendCtx = composerRef.current?.getSendContext(); @@ -2484,10 +3063,10 @@ export default function ChatView(props: ChatViewProps) { imageCount: composerImages.length, terminalContexts: composerTerminalContexts, }); - if (showPlanFollowUpPrompt && activeProposedPlan) { + if (currentConversation.showPlanFollowUpPrompt && currentConversation.activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, - planMarkdown: activeProposedPlan.planMarkdown, + planMarkdown: currentConversation.activeProposedPlan.planMarkdown, }); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); @@ -2525,7 +3104,7 @@ export default function ChatView(props: ChatViewProps) { } if (!activeProject) return; const threadIdForSend = activeThread.id; - const isFirstMessage = !isServerThread || activeThread.messages.length === 0; + const isFirstMessage = !isServerThread || activeMessageCount === 0; const baseBranchForWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath ? activeThread.branch @@ -2575,17 +3154,14 @@ export default function ChatView(props: ChatViewProps) { sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); - setOptimisticUserMessages((existing) => [ - ...existing, - { - id: messageIdForSend, - role: "user", - text: outgoingMessageText, - ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), - createdAt: messageCreatedAt, - streaming: false, - }, - ]); + messagesPaneRef.current?.addOptimisticUserMessage({ + id: messageIdForSend, + role: "user", + text: outgoingMessageText, + ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), + createdAt: messageCreatedAt, + streaming: false, + }); // Sending a message should always bring the latest user turn into view. shouldAutoScrollRef.current = true; forceStickToBottom(); @@ -2713,14 +3289,7 @@ export default function ChatView(props: ChatViewProps) { composerImagesRef.current.length === 0 && composerTerminalContextsRef.current.length === 0 ) { - setOptimisticUserMessages((existing) => { - const removed = existing.filter((message) => message.id === messageIdForSend); - for (const message of removed) { - revokeUserMessagePreviewUrls(message); - } - const next = existing.filter((message) => message.id !== messageIdForSend); - return next.length === existing.length ? existing : next; - }); + messagesPaneRef.current?.removeOptimisticUserMessage(messageIdForSend); promptRef.current = promptForSend; const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); composerImagesRef.current = retryComposerImages; @@ -2743,9 +3312,13 @@ export default function ChatView(props: ChatViewProps) { if (!turnStartSucceeded) { resetLocalDispatch(); } - }; + }); + + const onSend = useCallback((e?: { preventDefault: () => void }) => { + void handleSend(e); + }, []); - const onInterrupt = async () => { + const handleInterrupt = useEffectEvent(async () => { const api = readEnvironmentApi(environmentId); if (!api || !activeThread) return; await api.orchestration.dispatchCommand({ @@ -2754,7 +3327,11 @@ export default function ChatView(props: ChatViewProps) { threadId: activeThread.id, createdAt: new Date().toISOString(), }); - }; + }); + + const onInterrupt = useCallback(() => { + void handleInterrupt(); + }, []); const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { @@ -2812,112 +3389,68 @@ export default function ChatView(props: ChatViewProps) { [activeThreadId, environmentId, setThreadError], ); - const setActivePendingUserInputQuestionIndex = useCallback( - (nextQuestionIndex: number) => { - if (!activePendingUserInput) { - return; - } - setPendingUserInputQuestionIndexByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: nextQuestionIndex, - })); - }, - [activePendingUserInput], - ); - - const onSelectActivePendingUserInputOption = useCallback( - (questionId: string, optionLabel: string) => { - if (!activePendingUserInput) { - return; - } - setPendingUserInputAnswersByRequestId((existing) => { - const question = - (activePendingProgress?.activeQuestion?.id === questionId - ? activePendingProgress.activeQuestion - : undefined) ?? - activePendingUserInput.questions.find((entry) => entry.id === questionId); - if (!question) { - return existing; - } - - return { - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: togglePendingUserInputOptionSelection( - question, - existing[activePendingUserInput.requestId]?.[questionId], - optionLabel, - ), - }, - }; - }); - promptRef.current = ""; - composerRef.current?.resetCursorState({ cursor: 0 }); - }, - [activePendingProgress?.activeQuestion, activePendingUserInput], - ); - - const onChangeActivePendingUserInputCustomAnswer = useCallback( - ( - questionId: string, - value: string, - nextCursor: number, - expandedCursor: number, - _cursorAdjacentToMention: boolean, - ) => { - if (!activePendingUserInput) { - return; - } - promptRef.current = value; - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: setPendingUserInputCustomAnswer( - existing[activePendingUserInput.requestId]?.[questionId], - value, - ), - }, - })); - const snapshot = composerRef.current?.readSnapshot(); - if ( - snapshot?.value !== value || - snapshot.cursor !== nextCursor || - snapshot.expandedCursor !== expandedCursor - ) { - composerRef.current?.focusAt(nextCursor); - } - }, - [activePendingUserInput], - ); - - const onAdvanceActivePendingUserInput = useCallback(() => { - if (!activePendingUserInput || !activePendingProgress) { - return; - } - if (activePendingProgress.isLastQuestion) { - if (activePendingResolvedAnswers) { - void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); - } - return; - } - setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); + const resolveCurrentConversationDispatchState = useCallback(() => { + const threadActivities = isServerThread + ? getCurrentServerActivities() + : (localDraftThread?.activities ?? EMPTY_ACTIVITIES); + const threadProposedPlans = isServerThread + ? getCurrentServerProposedPlans() + : (localDraftThread?.proposedPlans ?? EMPTY_PROPOSED_PLANS); + const runtimeState = getCurrentConversationRuntimeState(); + const nextLatestTurnSettled = isLatestTurnSettled( + runtimeState.latestTurn, + runtimeState.session, + ); + const nextPendingUserInputs = derivePendingUserInputs(threadActivities); + const nextActivePendingUserInput = nextPendingUserInputs[0] ?? null; + const nextActivePendingDraftAnswers = nextActivePendingUserInput + ? (pendingUserInputAnswersByRequestId[nextActivePendingUserInput.requestId] ?? + EMPTY_PENDING_USER_INPUT_ANSWERS) + : EMPTY_PENDING_USER_INPUT_ANSWERS; + const nextActivePendingQuestionIndex = nextActivePendingUserInput + ? (pendingUserInputQuestionIndexByRequestId[nextActivePendingUserInput.requestId] ?? 0) + : 0; + const nextActivePendingProgress = nextActivePendingUserInput + ? derivePendingUserInputProgress( + nextActivePendingUserInput.questions, + nextActivePendingDraftAnswers, + nextActivePendingQuestionIndex, + ) + : null; + const nextActivePendingResolvedAnswers = nextActivePendingUserInput + ? buildPendingUserInputAnswers( + nextActivePendingUserInput.questions, + nextActivePendingDraftAnswers, + ) + : null; + const nextActiveProposedPlan = nextLatestTurnSettled + ? findLatestProposedPlan(threadProposedPlans, runtimeState.latestTurn?.turnId ?? null) + : null; + const nextShowPlanFollowUpPrompt = + nextPendingUserInputs.length === 0 && + interactionMode === "plan" && + nextLatestTurnSettled && + hasActionableProposedPlan(nextActiveProposedPlan); + + return { + activePendingUserInput: nextActivePendingUserInput, + activePendingProgress: nextActivePendingProgress, + activePendingResolvedAnswers: nextActivePendingResolvedAnswers, + activeProposedPlan: nextActiveProposedPlan, + showPlanFollowUpPrompt: nextShowPlanFollowUpPrompt, + }; }, [ - activePendingProgress, - activePendingResolvedAnswers, - activePendingUserInput, - onRespondToUserInput, - setActivePendingUserInputQuestionIndex, + getCurrentConversationRuntimeState, + getCurrentServerActivities, + getCurrentServerProposedPlans, + interactionMode, + isServerThread, + localDraftThread?.activities, + localDraftThread?.proposedPlans, + pendingUserInputAnswersByRequestId, + pendingUserInputQuestionIndexByRequestId, ]); - const onPreviousActivePendingUserInputQuestion = useCallback(() => { - if (!activePendingProgress) { - return; - } - setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); - }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); - const onSubmitPlanFollowUp = useCallback( async ({ text, @@ -2942,6 +3475,8 @@ export default function ChatView(props: ChatViewProps) { if (!trimmed) { return; } + const currentConversation = resolveCurrentConversationDispatchState(); + const currentProposedPlan = currentConversation.activeProposedPlan; const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx) { @@ -2969,16 +3504,13 @@ export default function ChatView(props: ChatViewProps) { sendInFlightRef.current = true; beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); - setOptimisticUserMessages((existing) => [ - ...existing, - { - id: messageIdForSend, - role: "user", - text: outgoingMessageText, - createdAt: messageCreatedAt, - streaming: false, - }, - ]); + messagesPaneRef.current?.addOptimisticUserMessage({ + id: messageIdForSend, + role: "user", + text: outgoingMessageText, + createdAt: messageCreatedAt, + streaming: false, + }); shouldAutoScrollRef.current = true; forceStickToBottom(); @@ -3012,11 +3544,11 @@ export default function ChatView(props: ChatViewProps) { titleSeed: activeThread.title, runtimeMode, interactionMode: nextInteractionMode, - ...(nextInteractionMode === "default" && activeProposedPlan + ...(nextInteractionMode === "default" && currentProposedPlan ? { sourceProposedPlan: { threadId: activeThread.id, - planId: activeProposedPlan.id, + planId: currentProposedPlan.id, }, } : {}), @@ -3031,9 +3563,7 @@ export default function ChatView(props: ChatViewProps) { } sendInFlightRef.current = false; } catch (err) { - setOptimisticUserMessages((existing) => - existing.filter((message) => message.id !== messageIdForSend), - ); + messagesPaneRef.current?.removeOptimisticUserMessage(messageIdForSend); setThreadError( threadIdForSend, err instanceof Error ? err.message : "Failed to send plan follow-up.", @@ -3044,7 +3574,6 @@ export default function ChatView(props: ChatViewProps) { }, [ activeThread, - activeProposedPlan, beginLocalDispatch, forceStickToBottom, isConnecting, @@ -3056,16 +3585,19 @@ export default function ChatView(props: ChatViewProps) { setComposerDraftInteractionMode, setThreadError, environmentId, + resolveCurrentConversationDispatchState, ], ); const onImplementPlanInNewThread = useCallback(async () => { const api = readEnvironmentApi(environmentId); + const currentConversation = resolveCurrentConversationDispatchState(); + const currentProposedPlan = currentConversation.activeProposedPlan; if ( !api || !activeThread || !activeProject || - !activeProposedPlan || + !currentProposedPlan || !isServerThread || isSendBusy || isConnecting || @@ -3088,7 +3620,7 @@ export default function ChatView(props: ChatViewProps) { const createdAt = new Date().toISOString(); const nextThreadId = newThreadId(); - const planMarkdown = activeProposedPlan.planMarkdown; + const planMarkdown = currentProposedPlan.planMarkdown; const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); const outgoingImplementationPrompt = formatOutgoingPrompt({ provider: ctxSelectedProvider, @@ -3138,7 +3670,7 @@ export default function ChatView(props: ChatViewProps) { interactionMode: "default", sourceProposedPlan: { threadId: activeThread.id, - planId: activeProposedPlan.id, + planId: currentProposedPlan.id, }, createdAt, }); @@ -3175,7 +3707,6 @@ export default function ChatView(props: ChatViewProps) { .then(finish, finish); }, [ activeProject, - activeProposedPlan, activeThread, beginLocalDispatch, isConnecting, @@ -3185,6 +3716,7 @@ export default function ChatView(props: ChatViewProps) { resetLocalDispatch, runtimeMode, environmentId, + resolveCurrentConversationDispatchState, ]); const onProviderModelSelect = useCallback( @@ -3241,14 +3773,8 @@ export default function ChatView(props: ChatViewProps) { ], ); - const onToggleWorkGroup = useCallback((groupId: string) => { - setExpandedWorkGroups((existing) => ({ - ...existing, - [groupId]: !existing[groupId], - })); - }, []); const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { - setExpandedImage(preview); + messagesPaneRef.current?.openExpandedImage(preview); }, []); const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { @@ -3272,17 +3798,6 @@ export default function ChatView(props: ChatViewProps) { }, [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], ); - const onRevertUserMessage = useCallback( - (messageId: MessageId) => { - const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); - if (typeof targetTurnCount !== "number") { - return; - } - void onRevertToTurnCount(targetTurnCount); - }, - [onRevertToTurnCount, revertTurnCountByUserMessageId], - ); - // Empty state: no active thread if (!activeThread) { return ; @@ -3336,109 +3851,60 @@ export default function ChatView(props: ChatViewProps) {
{/* Chat column */}
- {/* Messages Wrapper */} -
- {/* Messages */} -
- 0} - isWorking={isWorking} - activeTurnInProgress={isWorking || !latestTurnSettled} - activeTurnId={activeLatestTurn?.turnId ?? null} - activeTurnStartedAt={activeWorkStartedAt} - scrollContainer={messagesScrollElement} - timelineEntries={timelineEntries} - completionDividerBeforeEntryId={completionDividerBeforeEntryId} - completionSummary={completionSummary} - turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} - nowIso={nowIso} - activeThreadEnvironmentId={activeThread.environmentId} - expandedWorkGroups={expandedWorkGroups} - onToggleWorkGroup={onToggleWorkGroup} - changedFilesExpandedByTurnId={changedFilesExpandedByTurnId} - onSetChangedFilesExpanded={handleSetChangedFilesExpanded} - onOpenTurnDiff={onOpenTurnDiff} - revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} - onRevertUserMessage={onRevertUserMessage} - isRevertingCheckpoint={isRevertingCheckpoint} - onImageExpand={onExpandTimelineImage} - markdownCwd={gitCwd ?? undefined} - resolvedTheme={resolvedTheme} - timestampFormat={timestampFormat} - workspaceRoot={activeWorkspaceRoot} - /> -
- - {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} - {showScrollToBottom && ( -
- -
- )} -
+ {/* Input bar */}
-
@@ -3506,24 +3971,19 @@ export default function ChatView(props: ChatViewProps) { {/* end chat column */} {/* Plan sidebar */} - {planSidebarOpen ? ( - { - setPlanSidebarOpen(false); - // Track that the user explicitly dismissed for this turn so auto-open won't fight them. - const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; - if (turnKey) { - planSidebarDismissedForTurnRef.current = turnKey; - } - }} - /> - ) : null} +
{/* end horizontal flex container */} @@ -3543,10 +4003,8 @@ export default function ChatView(props: ChatViewProps) { onAddTerminalContext={addTerminalContextToDraft} /> ))} - - {expandedImage && ( - - )}
); } + +export default memo(ChatView); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 63a5231f73..c0de8622f9 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -3,7 +3,7 @@ import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/reac import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { scopeThreadRef } from "@t3tools/client-runtime"; -import type { TurnId } from "@t3tools/contracts"; +import type { ThreadId, TimestampFormat, TurnId } from "@t3tools/contracts"; import { ChevronLeftIcon, ChevronRightIcon, @@ -13,6 +13,7 @@ import { } from "lucide-react"; import { type WheelEvent as ReactWheelEvent, + memo, useCallback, useEffect, useMemo, @@ -20,7 +21,7 @@ import { useState, } from "react"; import { openInPreferredEditor } from "../editorPreferences"; -import { useGitStatus } from "~/lib/gitStatusState"; +import { useGitStatusIsRepo } from "~/lib/gitStatusState"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { readLocalApi } from "../localApi"; @@ -31,10 +32,15 @@ import { buildPatchCacheKey } from "../lib/diffRendering"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { selectProjectByRef, useStore } from "../store"; -import { createThreadSelectorByRef } from "../storeSelectors"; +import { + type ThreadBranchToolbarSnapshot, + createThreadBranchToolbarSnapshotSelectorByRef, + createThreadTurnDiffSummariesSelectorByRef, +} from "../storeSelectors"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { useSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; +import type { TurnDiffSummary } from "../types"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; @@ -166,216 +172,58 @@ interface DiffPanelProps { export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; -export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { - const navigate = useNavigate(); - const { resolvedTheme } = useTheme(); - const settings = useSettings(); - const [diffRenderMode, setDiffRenderMode] = useState("stacked"); - const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); - const patchViewportRef = useRef(null); +const DiffPanelHeader = memo(function DiffPanelHeader(props: { + diffRenderMode: DiffRenderMode; + diffWordWrap: boolean; + inferredCheckpointTurnCountByTurnId: Record; + onDiffRenderModeChange: (next: DiffRenderMode) => void; + onDiffWordWrapChange: (next: boolean) => void; + onSelectTurn: (turnId: TurnId) => void; + onSelectWholeConversation: () => void; + orderedTurnDiffSummaries: TurnDiffSummary[]; + selectedTurnId: TurnId | null; + timestampFormat: TimestampFormat; +}) { + const { + diffRenderMode, + diffWordWrap, + inferredCheckpointTurnCountByTurnId, + onDiffRenderModeChange, + onDiffWordWrapChange, + onSelectTurn, + onSelectWholeConversation, + orderedTurnDiffSummaries, + selectedTurnId, + timestampFormat, + } = props; const turnStripRef = useRef(null); - const previousDiffOpenRef = useRef(false); - const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); - const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); - const routeThreadRef = useParams({ - strict: false, - select: (params) => resolveThreadRouteRef(params), - }); - const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); - const diffOpen = diffSearch.diff === "1"; - const activeThreadId = routeThreadRef?.threadId ?? null; - const activeThread = useStore( - useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), - ); - const activeProjectId = activeThread?.projectId ?? null; - const activeProject = useStore((store) => - activeThread && activeProjectId - ? selectProjectByRef(store, { - environmentId: activeThread.environmentId, - projectId: activeProjectId, - }) - : undefined, - ); - const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitStatusQuery = useGitStatus({ - environmentId: activeThread?.environmentId ?? null, - cwd: activeCwd ?? null, + const [turnStripScrollState, setTurnStripScrollState] = useState({ + canScrollLeft: false, + canScrollRight: false, }); - const isGitRepo = gitStatusQuery.data?.isRepo ?? true; - const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = - useTurnDiffSummaries(activeThread); - const orderedTurnDiffSummaries = useMemo( - () => - [...turnDiffSummaries].toSorted((left, right) => { - const leftTurnCount = - left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0; - const rightTurnCount = - right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0; - if (leftTurnCount !== rightTurnCount) { - return rightTurnCount - leftTurnCount; - } - return right.completedAt.localeCompare(left.completedAt); - }), - [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], - ); - const selectedTurnId = diffSearch.diffTurnId ?? null; - const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; - const selectedTurn = - selectedTurnId === null - ? undefined - : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? - orderedTurnDiffSummaries[0]); - const selectedCheckpointTurnCount = - selectedTurn && - (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); - const selectedCheckpointRange = useMemo( - () => - typeof selectedCheckpointTurnCount === "number" - ? { - fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1), - toTurnCount: selectedCheckpointTurnCount, - } - : null, - [selectedCheckpointTurnCount], - ); - const conversationCheckpointTurnCount = useMemo(() => { - const turnCounts = orderedTurnDiffSummaries - .map( - (summary) => - summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId], - ) - .filter((value): value is number => typeof value === "number"); - if (turnCounts.length === 0) { - return undefined; - } - const latest = Math.max(...turnCounts); - return latest > 0 ? latest : undefined; - }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]); - const conversationCheckpointRange = useMemo( - () => - !selectedTurn && typeof conversationCheckpointTurnCount === "number" - ? { - fromTurnCount: 0, - toTurnCount: conversationCheckpointTurnCount, - } - : null, - [conversationCheckpointTurnCount, selectedTurn], - ); - const activeCheckpointRange = selectedTurn - ? selectedCheckpointRange - : conversationCheckpointRange; - const conversationCacheScope = useMemo(() => { - if (selectedTurn || orderedTurnDiffSummaries.length === 0) { - return null; - } - return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; - }, [orderedTurnDiffSummaries, selectedTurn]); - const activeCheckpointDiffQuery = useQuery( - checkpointDiffQueryOptions({ - environmentId: activeThread?.environmentId ?? null, - threadId: activeThreadId, - fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, - toTurnCount: activeCheckpointRange?.toTurnCount ?? null, - cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, - enabled: isGitRepo, - }), - ); - const selectedTurnCheckpointDiff = selectedTurn - ? activeCheckpointDiffQuery.data?.diff - : undefined; - const conversationCheckpointDiff = selectedTurn - ? undefined - : activeCheckpointDiffQuery.data?.diff; - const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isLoading; - const checkpointDiffError = - activeCheckpointDiffQuery.error instanceof Error - ? activeCheckpointDiffQuery.error.message - : activeCheckpointDiffQuery.error - ? "Failed to load checkpoint diff." - : null; - - const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; - const hasResolvedPatch = typeof selectedPatch === "string"; - const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; - const renderablePatch = useMemo( - () => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`), - [resolvedTheme, selectedPatch], - ); - const renderableFiles = useMemo(() => { - if (!renderablePatch || renderablePatch.kind !== "files") { - return []; - } - return renderablePatch.files.toSorted((left, right) => - resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, { - numeric: true, - sensitivity: "base", - }), - ); - }, [renderablePatch]); - - useEffect(() => { - if (diffOpen && !previousDiffOpenRef.current) { - setDiffWordWrap(settings.diffWordWrap); - } - previousDiffOpenRef.current = diffOpen; - }, [diffOpen, settings.diffWordWrap]); - - useEffect(() => { - if (!selectedFilePath || !patchViewportRef.current) { - return; - } - const target = Array.from( - patchViewportRef.current.querySelectorAll("[data-diff-file-path]"), - ).find((element) => element.dataset.diffFilePath === selectedFilePath); - target?.scrollIntoView({ block: "nearest" }); - }, [selectedFilePath, renderableFiles]); - - const openDiffFileInEditor = useCallback( - (filePath: string) => { - const api = readLocalApi(); - if (!api) return; - const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; - void openInPreferredEditor(api, targetPath).catch((error) => { - console.warn("Failed to open diff file in editor.", error); - }); - }, - [activeCwd], - ); - - const selectTurn = (turnId: TurnId) => { - if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1", diffTurnId: turnId }; - }, - }); - }; - const selectWholeConversation = () => { - if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; - }, - }); - }; const updateTurnStripScrollState = useCallback(() => { const element = turnStripRef.current; if (!element) { - setCanScrollTurnStripLeft(false); - setCanScrollTurnStripRight(false); + setTurnStripScrollState((previous) => + previous.canScrollLeft || previous.canScrollRight + ? { canScrollLeft: false, canScrollRight: false } + : previous, + ); return; } const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth); - setCanScrollTurnStripLeft(element.scrollLeft > 4); - setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4); + const nextCanScrollLeft = element.scrollLeft > 4; + const nextCanScrollRight = element.scrollLeft < maxScrollLeft - 4; + setTurnStripScrollState((previous) => + previous.canScrollLeft === nextCanScrollLeft && previous.canScrollRight === nextCanScrollRight + ? previous + : { + canScrollLeft: nextCanScrollLeft, + canScrollRight: nextCanScrollRight, + }, + ); }, []); const scrollTurnStripBy = useCallback((offset: number) => { const element = turnStripRef.current; @@ -424,27 +272,27 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const selectedChip = element.querySelector("[data-turn-chip-selected='true']"); selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); - }, [selectedTurn?.turnId, selectedTurnId]); + }, [selectedTurnId]); - const headerRow = ( + return ( <>
- {canScrollTurnStripLeft && ( + {turnStripScrollState.canScrollLeft && (
)} - {canScrollTurnStripRight && ( + {turnStripScrollState.canScrollRight && (
)}
); +}); + +const DiffPanelContent = memo(function DiffPanelContent(props: { + activeThreadId: ThreadId | null; + diffRenderMode: DiffRenderMode; + diffWordWrap: boolean; + orderedTurnDiffSummaries: TurnDiffSummary[]; + selectedFilePath: string | null; + selectedTurnId: TurnId | null; + workspaceSnapshot: ThreadBranchToolbarSnapshot | undefined; +}) { + const { + activeThreadId, + diffRenderMode, + diffWordWrap, + orderedTurnDiffSummaries, + selectedFilePath, + selectedTurnId, + workspaceSnapshot, + } = props; + const { resolvedTheme } = useTheme(); + const patchViewportRef = useRef(null); + const activeProject = useStore((store) => + workspaceSnapshot + ? selectProjectByRef(store, { + environmentId: workspaceSnapshot.environmentId, + projectId: workspaceSnapshot.projectId, + }) + : undefined, + ); + const activeCwd = workspaceSnapshot?.worktreePath ?? activeProject?.cwd ?? null; + const isGitRepo = useGitStatusIsRepo({ + environmentId: workspaceSnapshot?.environmentId ?? null, + cwd: activeCwd, + }); + const { inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(orderedTurnDiffSummaries); + const activeCheckpointSelection = useMemo(() => { + const selectedTurn = + selectedTurnId === null + ? undefined + : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? + orderedTurnDiffSummaries[0]); + const selectedCheckpointTurnCount = + selectedTurn && + (selectedTurn.checkpointTurnCount ?? + inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); + const selectedCheckpointRange = + typeof selectedCheckpointTurnCount === "number" + ? { + fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1), + toTurnCount: selectedCheckpointTurnCount, + } + : null; + const conversationCheckpointTurnCount = (() => { + const turnCounts = orderedTurnDiffSummaries + .map( + (summary) => + summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId], + ) + .filter((value): value is number => typeof value === "number"); + if (turnCounts.length === 0) { + return undefined; + } + const latest = Math.max(...turnCounts); + return latest > 0 ? latest : undefined; + })(); + const conversationCheckpointRange = + !selectedTurn && typeof conversationCheckpointTurnCount === "number" + ? { + fromTurnCount: 0, + toTurnCount: conversationCheckpointTurnCount, + } + : null; + + return { + conversationCacheScope: + selectedTurn || orderedTurnDiffSummaries.length === 0 + ? null + : `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`, + selectedTurn, + activeCheckpointRange: selectedTurn ? selectedCheckpointRange : conversationCheckpointRange, + }; + }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries, selectedTurnId]); + const activeCheckpointDiffQuery = useQuery( + checkpointDiffQueryOptions({ + environmentId: workspaceSnapshot?.environmentId ?? null, + threadId: activeThreadId, + fromTurnCount: activeCheckpointSelection.activeCheckpointRange?.fromTurnCount ?? null, + toTurnCount: activeCheckpointSelection.activeCheckpointRange?.toTurnCount ?? null, + cacheScope: activeCheckpointSelection.selectedTurn + ? `turn:${activeCheckpointSelection.selectedTurn.turnId}` + : activeCheckpointSelection.conversationCacheScope, + enabled: isGitRepo, + }), + ); + const selectedPatch = activeCheckpointDiffQuery.data?.diff; + const hasResolvedPatch = typeof selectedPatch === "string"; + const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; + const checkpointDiffError = + activeCheckpointDiffQuery.error instanceof Error + ? activeCheckpointDiffQuery.error.message + : activeCheckpointDiffQuery.error + ? "Failed to load checkpoint diff." + : null; + const renderablePatch = useMemo( + () => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`), + [resolvedTheme, selectedPatch], + ); + const renderableFiles = useMemo(() => { + if (!renderablePatch || renderablePatch.kind !== "files") { + return []; + } + return renderablePatch.files.toSorted((left, right) => + resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, { + numeric: true, + sensitivity: "base", + }), + ); + }, [renderablePatch]); + + useEffect(() => { + if (!selectedFilePath || !patchViewportRef.current) { + return; + } + const target = Array.from( + patchViewportRef.current.querySelectorAll("[data-diff-file-path]"), + ).find((element) => element.dataset.diffFilePath === selectedFilePath); + target?.scrollIntoView({ block: "nearest" }); + }, [selectedFilePath, renderableFiles]); + + const openDiffFileInEditor = useCallback( + (filePath: string) => { + const api = readLocalApi(); + if (!api) return; + const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; + void openInPreferredEditor(api, targetPath).catch((error) => { + console.warn("Failed to open diff file in editor.", error); + }); + }, + [activeCwd], + ); + + if (!workspaceSnapshot) { + return ( +
+ Select a thread to inspect turn diffs. +
+ ); + } + + if (!isGitRepo) { + return ( +
+ Turn diffs are unavailable because this project is not a git repository. +
+ ); + } + + if (orderedTurnDiffSummaries.length === 0) { + return ( +
+ No completed turns yet. +
+ ); + } return ( - - {!activeThread ? ( -
- Select a thread to inspect turn diffs. +
+ {checkpointDiffError && !renderablePatch && ( +
+

{checkpointDiffError}

- ) : !isGitRepo ? ( -
- Turn diffs are unavailable because this project is not a git repository. -
- ) : orderedTurnDiffSummaries.length === 0 ? ( -
- No completed turns yet. -
- ) : ( - <> -
- {checkpointDiffError && !renderablePatch && ( -
-

{checkpointDiffError}

-
- )} - {!renderablePatch ? ( - isLoadingCheckpointDiff ? ( - - ) : ( -
-

- {hasNoNetChanges - ? "No net changes in this selection." - : "No patch available for this selection."} -

-
- ) - ) : renderablePatch.kind === "files" ? ( - + ) : ( +
+

+ {hasNoNetChanges + ? "No net changes in this selection." + : "No patch available for this selection."} +

+
+ ) + ) : renderablePatch.kind === "files" ? ( + + {renderableFiles.map((fileDiff) => { + const filePath = resolveFileDiffPath(fileDiff); + const fileKey = buildFileDiffRenderKey(fileDiff); + const themedFileKey = `${fileKey}:${resolvedTheme}`; + return ( +
{ + const nativeEvent = event.nativeEvent as MouseEvent; + const composedPath = nativeEvent.composedPath?.() ?? []; + const clickedHeader = composedPath.some((node) => { + if (!(node instanceof Element)) return false; + return node.hasAttribute("data-title"); + }); + if (!clickedHeader) return; + openDiffFileInEditor(filePath); }} > - {renderableFiles.map((fileDiff) => { - const filePath = resolveFileDiffPath(fileDiff); - const fileKey = buildFileDiffRenderKey(fileDiff); - const themedFileKey = `${fileKey}:${resolvedTheme}`; - return ( -
{ - const nativeEvent = event.nativeEvent as MouseEvent; - const composedPath = nativeEvent.composedPath?.() ?? []; - const clickedHeader = composedPath.some((node) => { - if (!(node instanceof Element)) return false; - return node.hasAttribute("data-title"); - }); - if (!clickedHeader) return; - openDiffFileInEditor(filePath); - }} - > - -
- ); - })} - - ) : ( -
-
-

{renderablePatch.reason}

-
-                    {renderablePatch.text}
-                  
-
+
- )} + ); + })} + + ) : ( +
+
+

{renderablePatch.reason}

+
+              {renderablePatch.text}
+            
- +
)} +
+ ); +}); + +export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { + const navigate = useNavigate(); + const settings = useSettings(); + const [diffRenderMode, setDiffRenderMode] = useState("stacked"); + const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); + const previousDiffOpenRef = useRef(false); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); + const diffOpen = diffSearch.diff === "1"; + const activeThreadId = routeThreadRef?.threadId ?? null; + const workspaceSnapshot = useStore( + useMemo(() => createThreadBranchToolbarSnapshotSelectorByRef(routeThreadRef), [routeThreadRef]), + ); + const turnDiffSummaries = useStore( + useMemo(() => createThreadTurnDiffSummariesSelectorByRef(routeThreadRef), [routeThreadRef]), + ); + const { inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(turnDiffSummaries); + const orderedTurnDiffSummaries = useMemo( + () => + [...turnDiffSummaries].toSorted((left, right) => { + const leftTurnCount = + left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0; + const rightTurnCount = + right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0; + if (leftTurnCount !== rightTurnCount) { + return rightTurnCount - leftTurnCount; + } + return right.completedAt.localeCompare(left.completedAt); + }), + [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], + ); + + const selectedTurnId = diffSearch.diffTurnId ?? null; + const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; + + useEffect(() => { + if (diffOpen && !previousDiffOpenRef.current) { + setDiffWordWrap(settings.diffWordWrap); + } + previousDiffOpenRef.current = diffOpen; + }, [diffOpen, settings.diffWordWrap]); + + const selectTurn = useCallback( + (turnId: TurnId) => { + if (!routeThreadRef) return; + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(routeThreadRef.environmentId, routeThreadRef.threadId), + ), + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return { ...rest, diff: "1", diffTurnId: turnId }; + }, + }); + }, + [navigate, routeThreadRef], + ); + const selectWholeConversation = useCallback(() => { + if (!routeThreadRef) return; + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(routeThreadRef.environmentId, routeThreadRef.threadId), + ), + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return { ...rest, diff: "1" }; + }, + }); + }, [navigate, routeThreadRef]); + + return ( + + } + > + ); } diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 8c742cbe9b..c056e1bb08 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -11,7 +11,7 @@ import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; -export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; +export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 75; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; @@ -20,6 +20,12 @@ type SidebarProject = { updatedAt?: string | undefined; }; +type SidebarThreadSortInput = Pick & { + projectId: string; + latestUserMessageAt?: string | null; + messages?: Pick[]; +}; + export type ThreadTraversalDirection = "previous" | "next"; export interface ThreadStatusPill { @@ -46,16 +52,28 @@ const THREAD_STATUS_PRIORITY: Record = { type ThreadStatusInput = Pick< SidebarThreadSummary, - | "hasActionableProposedPlan" - | "hasPendingApprovals" - | "hasPendingUserInput" - | "interactionMode" - | "latestTurn" - | "session" + "hasActionableProposedPlan" | "hasPendingApprovals" | "hasPendingUserInput" | "interactionMode" > & { + latestTurn: ThreadStatusLatestTurnSnapshot | null; + session: ThreadStatusSessionSnapshot | null; lastVisitedAt?: string | undefined; }; +type LatestTurnSnapshot = NonNullable; +type SessionSnapshot = NonNullable; + +export interface ThreadStatusLatestTurnSnapshot { + turnId: LatestTurnSnapshot["turnId"]; + startedAt: LatestTurnSnapshot["startedAt"]; + completedAt: LatestTurnSnapshot["completedAt"]; +} + +export interface ThreadStatusSessionSnapshot { + orchestrationStatus: SessionSnapshot["orchestrationStatus"]; + activeTurnId?: SessionSnapshot["activeTurnId"]; + status: SessionSnapshot["status"]; +} + export interface ThreadJumpHintVisibilityController { sync: (shouldShow: boolean) => void; dispose: () => void; @@ -469,6 +487,14 @@ export function getFallbackThreadIdAfterDelete< )[0]?.id ?? null ); } + +export function sortThreadsForSidebar & ThreadSortInput>( + threads: readonly TThread[], + sortOrder: SidebarThreadSortOrder, +): TThread[] { + return sortThreads(threads, sortOrder); +} + export function getProjectSortTimestamp( project: SidebarProject, projectThreads: readonly ThreadSortInput[], @@ -489,7 +515,7 @@ export function getProjectSortTimestamp( export function sortProjectsForSidebar< TProject extends SidebarProject, - TThread extends Pick & ThreadSortInput, + TThread extends SidebarThreadSortInput, >( projects: readonly TProject[], threads: readonly TThread[], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 03ae979017..ca59d30a73 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -36,19 +36,19 @@ import { type DesktopUpdateState, type EnvironmentId, ProjectId, - type ScopedProjectRef, type ScopedThreadRef, type ThreadEnvMode, ThreadId, type GitStatusResult, } from "@t3tools/contracts"; import { + parseScopedThreadKey, scopedProjectKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; -import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import { Link, useLocation, useNavigate, useRouter } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, type SidebarThreadSortOrder, @@ -56,38 +56,26 @@ import { import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; -import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { selectProjectByRef, selectProjectsAcrossEnvironments, - selectSidebarThreadsForProjectRef, + selectSidebarThreadSummaryByRef, selectSidebarThreadsForProjectRefs, - selectSidebarThreadsAcrossEnvironments, + selectThreadIdsByProjectRef, selectThreadByRef, useStore, } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; -import { - resolveShortcutCommand, - shortcutLabelForCommand, - shouldShowThreadJumpHints, - threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, -} from "../keybindings"; +import { shortcutLabelForCommand } from "../keybindings"; import { useGitStatus } from "../lib/gitStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions"; -import { - buildThreadRouteParams, - resolveThreadRouteRef, - resolveThreadRouteTarget, -} from "../threadRoutes"; +import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; @@ -122,7 +110,6 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { - resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, @@ -130,25 +117,48 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, - shouldClearThreadSelectionOnMouseDown, - sortProjectsForSidebar, - useThreadJumpHintVisibility, + sortThreadsForSidebar, ThreadStatusPill, } from "./Sidebar.logic"; -import { sortThreads } from "../lib/threadSort"; +import { + createSidebarSortedProjectKeysSelector, + createSidebarProjectRenderStateSelector, + createSidebarProjectThreadStatusInputsSelector, + createSidebarThreadMetaSnapshotSelectorByRef, + createSidebarThreadRowSnapshotSelectorByRef, + createSidebarThreadStatusInputSelectorByRef, + type ProjectThreadStatusInput, +} from "./sidebar/sidebarSelectors"; +import { THREAD_PREVIEW_LIMIT } from "./sidebar/sidebarConstants"; +import { + SidebarKeyboardController, + SidebarSelectionController, +} from "./sidebar/sidebarControllers"; +import { + collapseSidebarProjectThreadList, + expandSidebarProjectThreadList, + resetSidebarViewState, + useSidebarIsActiveThread, + useSidebarProjectActiveRouteThreadKey, + useSidebarProjectThreadListExpanded, + useSidebarThreadJumpLabel, +} from "./sidebar/sidebarViewStore"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; +import { + buildSidebarPhysicalToLogicalKeyMap, + buildSidebarProjectSnapshots, + type SidebarProjectSnapshot, +} from "./sidebar/sidebarProjectSnapshots"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import { deriveLogicalProjectKey } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; -import type { Project, SidebarThreadSummary } from "../types"; -const THREAD_PREVIEW_LIMIT = 6; +import type { LogicalProjectKey } from "../logicalProject"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -162,65 +172,7 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; -const EMPTY_THREAD_JUMP_LABELS = new Map(); - -function threadJumpLabelMapsEqual( - left: ReadonlyMap, - right: ReadonlyMap, -): boolean { - if (left === right) { - return true; - } - if (left.size !== right.size) { - return false; - } - for (const [key, value] of left) { - if (right.get(key) !== value) { - return false; - } - } - return true; -} - -function buildThreadJumpLabelMap(input: { - keybindings: ReturnType; - platform: string; - terminalOpen: boolean; - threadJumpCommandByKey: ReadonlyMap< - string, - NonNullable> - >; -}): ReadonlyMap { - if (input.threadJumpCommandByKey.size === 0) { - return EMPTY_THREAD_JUMP_LABELS; - } - - const shortcutLabelOptions = { - platform: input.platform, - context: { - terminalFocus: false, - terminalOpen: input.terminalOpen, - }, - } as const; - const mapping = new Map(); - for (const [threadKey, command] of input.threadJumpCommandByKey) { - const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); - if (label) { - mapping.set(threadKey, label); - } - } - return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; -} -type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; - -type SidebarProjectSnapshot = Project & { - projectKey: string; - environmentPresence: EnvironmentPresence; - memberProjectRefs: readonly ScopedProjectRef[]; - /** Labels for remote environments this project lives in. */ - remoteEnvironmentLabels: readonly string[]; -}; interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; @@ -236,6 +188,14 @@ interface PrStatusIndicator { type ThreadPr = GitStatusResult["pr"]; +function useSidebarThreadStatusInput( + threadRef: ScopedThreadRef | null, +): ProjectThreadStatusInput | undefined { + return useStore( + useMemo(() => createSidebarThreadStatusInputSelectorByRef(threadRef), [threadRef]), + ); +} + function ThreadStatusLabel({ status, compact = false, @@ -328,77 +288,301 @@ function resolveThreadPr( return gitStatus.pr ?? null; } -interface SidebarThreadRowProps { - thread: SidebarThreadSummary; - projectCwd: string | null; - orderedProjectThreadKeys: readonly string[]; - isActive: boolean; - jumpLabel: string | null; +const SidebarThreadMetaCluster = memo(function SidebarThreadMetaCluster(props: { appSettingsConfirmThreadArchive: boolean; - renamingThreadKey: string | null; - renamingTitle: string; - setRenamingTitle: (title: string) => void; - renamingInputRef: React.RefObject; - renamingCommittedRef: React.RefObject; - confirmingArchiveThreadKey: string | null; - setConfirmingArchiveThreadKey: React.Dispatch>; - confirmArchiveButtonRefs: React.RefObject>; - handleThreadClick: ( - event: React.MouseEvent, - threadRef: ScopedThreadRef, - orderedProjectThreadKeys: readonly string[], - ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => void; - handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; - handleThreadContextMenu: ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => Promise; - clearSelection: () => void; - commitRename: ( - threadRef: ScopedThreadRef, - newTitle: string, - originalTitle: string, - ) => Promise; - cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: (event: React.MouseEvent, prUrl: string) => void; -} - -const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + confirmArchiveButtonRef: React.RefObject; + handleArchiveImmediateClick: (event: React.MouseEvent) => void; + handleConfirmArchiveClick: (event: React.MouseEvent) => void; + handleStartArchiveConfirmation: (event: React.MouseEvent) => void; + isConfirmingArchive: boolean; + isHighlighted: boolean; + isRemoteThread: boolean; + stopPropagationOnPointerDown: (event: React.PointerEvent) => void; + threadEnvironmentLabel: string | null; + threadId: ThreadId; + threadKey: string; + threadRef: ScopedThreadRef; + threadTitle: string; +}) { const { - orderedProjectThreadKeys, - isActive, - jumpLabel, appSettingsConfirmThreadArchive, - renamingThreadKey, - renamingTitle, - setRenamingTitle, - renamingInputRef, - renamingCommittedRef, - confirmingArchiveThreadKey, - setConfirmingArchiveThreadKey, - confirmArchiveButtonRefs, - handleThreadClick, - navigateToThread, - handleMultiSelectContextMenu, - handleThreadContextMenu, - clearSelection, - commitRename, - cancelRename, - attemptArchiveThread, - openPrLink, - thread, + confirmArchiveButtonRef, + handleArchiveImmediateClick, + handleConfirmArchiveClick, + handleStartArchiveConfirmation, + isConfirmingArchive, + isHighlighted, + isRemoteThread, + stopPropagationOnPointerDown, + threadEnvironmentLabel, + threadId, + threadKey, + threadRef, + threadTitle, } = props; - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const threadKey = scopedThreadKey(threadRef); + const jumpLabel = useSidebarThreadJumpLabel(threadKey); + const metaSnapshot = useStore( + useMemo(() => createSidebarThreadMetaSnapshotSelectorByRef(threadRef), [threadRef]), + ); + const isThreadRunning = metaSnapshot?.isRunning ?? false; + const hidden = isConfirmingArchive && !isThreadRunning; + const relativeTimestamp = useMemo( + () => (metaSnapshot ? formatRelativeTimeLabel(metaSnapshot.activityTimestamp) : null), + [metaSnapshot], + ); + const isConfirmingArchiveVisible = isConfirmingArchive && !isThreadRunning; + + const archiveControl = useMemo(() => { + if (isConfirmingArchiveVisible) { + return ( + + ); + } + + if (isThreadRunning) { + return null; + } + + if (appSettingsConfirmThreadArchive) { + return ( +
+ +
+ ); + } + + return ( + + + +
+ } + /> + Archive + + ); + }, [ + appSettingsConfirmThreadArchive, + confirmArchiveButtonRef, + handleArchiveImmediateClick, + handleConfirmArchiveClick, + handleStartArchiveConfirmation, + isConfirmingArchiveVisible, + isThreadRunning, + stopPropagationOnPointerDown, + threadId, + threadTitle, + ]); + + return ( + <> + {archiveControl} + + + ); +}); + +const SidebarThreadStatusIndicator = memo(function SidebarThreadStatusIndicator(props: { + threadKey: string; + threadRef: ScopedThreadRef; +}) { + const { threadKey, threadRef } = props; + const statusInput = useSidebarThreadStatusInput(threadRef); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const threadStatus = useMemo( + () => + statusInput + ? resolveThreadStatusPill({ + thread: { + ...statusInput, + lastVisitedAt, + }, + }) + : null, + [lastVisitedAt, statusInput], + ); + + return threadStatus ? : null; +}); + +const SidebarThreadTerminalStatusIndicator = memo( + function SidebarThreadTerminalStatusIndicator(props: { threadRef: ScopedThreadRef }) { + const { threadRef } = props; + const runningTerminalIds = useTerminalStateStore( + (state) => + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, + ); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + + return terminalStatus ? ( + + + + ) : null; + }, +); + +interface SidebarThreadRowProps { + threadKey: string; + project: SidebarProjectSnapshot; +} + +const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + const { threadKey, project } = props; + const threadRef = useMemo(() => parseScopedThreadKey(threadKey), [threadKey]); + const threadSortOrder = useSettings( + (settings) => settings.sidebarThreadSortOrder, + ); + const appSettingsConfirmThreadDelete = useSettings( + (settings) => settings.confirmThreadDelete, + ); + const appSettingsConfirmThreadArchive = useSettings( + (settings) => settings.confirmThreadArchive, + ); + const router = useRouter(); + const { archiveThread, deleteThread } = useThreadActions(); + const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); + const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); + const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); + const clearSelection = useThreadSelectionStore((state) => state.clearSelection); + const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); + const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); + const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ + threadId: ThreadId; + }>({ + onCopy: (ctx) => { + toastManager.add({ + type: "success", + title: "Thread ID copied", + description: ctx.threadId, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy thread ID", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + }); + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ + path: string; + }>({ + onCopy: (ctx) => { + toastManager.add({ + type: "success", + title: "Path copied", + description: ctx.path, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + }); + const [isRenaming, setIsRenaming] = useState(false); + const [renamingTitle, setRenamingTitle] = useState(""); + const [isConfirmingArchive, setIsConfirmingArchive] = useState(false); + const renamingCommittedRef = useRef(false); + const renamingInputRef = useRef(null); + const confirmArchiveButtonRef = useRef(null); + if (!threadRef) { + return null; + } + const thread = useStore( + useMemo(() => createSidebarThreadRowSnapshotSelectorByRef(threadRef), [threadRef]), + ); + const isActive = useSidebarIsActiveThread(threadKey); + if (!thread) { + return null; + } const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0); - const runningTerminalIds = useTerminalStateStore( - (state) => - selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, - ); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; @@ -422,32 +606,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP [thread.environmentId, thread.projectId], ), ); - const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; + const gitCwd = thread.worktreePath ?? threadProjectCwd ?? project?.cwd ?? null; const gitStatus = useGitStatus({ environmentId: thread.environmentId, cwd: thread.branch != null ? gitCwd : null, }); const isHighlighted = isActive || isSelected; - const isThreadRunning = - thread.session?.status === "running" && thread.session.activeTurnId != null; - const threadStatus = resolveThreadStatusPill({ - thread: { - ...thread, - lastVisitedAt, - }, - }); const pr = resolveThreadPr(thread.branch, gitStatus.data); const prStatus = prStatusIndicator(pr); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; - const threadMetaClassName = isConfirmingArchive - ? "pointer-events-none opacity-0" - : !isThreadRunning - ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" - : "pointer-events-none"; const clearConfirmingArchive = useCallback(() => { - setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); - }, [setConfirmingArchiveThreadKey, threadKey]); + setIsConfirmingArchive(false); + }, []); const handleMouseLeave = useCallback(() => { clearConfirmingArchive(); }, [clearConfirmingArchive]); @@ -463,104 +632,351 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }, [clearConfirmingArchive], ); - const handleRowClick = useCallback( - (event: React.MouseEvent) => { - handleThreadClick(event, threadRef, orderedProjectThreadKeys); - }, - [handleThreadClick, orderedProjectThreadKeys, threadRef], - ); - const handleRowKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - navigateToThread(threadRef); - }, - [navigateToThread, threadRef], - ); - const handleRowContextMenu = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - if (hasSelection && isSelected) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - return; - } + const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { + event.preventDefault(); + event.stopPropagation(); - if (hasSelection) { + const api = readLocalApi(); + if (!api) { + toastManager.add({ + type: "error", + title: "Link opening is unavailable.", + }); + return; + } + + void api.shell.openExternal(prUrl).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open PR link", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + }, []); + const navigateToThread = useCallback( + (targetThreadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { clearSelection(); } - void handleThreadContextMenu(threadRef, { - x: event.clientX, - y: event.clientY, + setSelectionAnchor(scopedThreadKey(targetThreadRef)); + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(targetThreadRef), }); }, - [ - clearSelection, - handleMultiSelectContextMenu, - handleThreadContextMenu, - hasSelection, - isSelected, - threadRef, - ], - ); - const handlePrClick = useCallback( - (event: React.MouseEvent) => { - if (!prStatus) return; - openPrLink(event, prStatus.url); - }, - [openPrLink, prStatus], - ); - const handleRenameInputRef = useCallback( - (element: HTMLInputElement | null) => { - if (element && renamingInputRef.current !== element) { - renamingInputRef.current = element; - element.focus(); - element.select(); - } - }, - [renamingInputRef], - ); - const handleRenameInputChange = useCallback( - (event: React.ChangeEvent) => { - setRenamingTitle(event.target.value); - }, - [setRenamingTitle], + [clearSelection, router, setSelectionAnchor], ); - const handleRenameInputKeyDown = useCallback( - (event: React.KeyboardEvent) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(threadRef, renamingTitle, thread.title); - } else if (event.key === "Escape") { - event.preventDefault(); + const attemptArchiveThread = useCallback(async () => { + try { + await archiveThread(threadRef); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, [archiveThread, threadRef]); + const cancelRename = useCallback(() => { + setIsRenaming(false); + setRenamingTitle(""); + renamingCommittedRef.current = false; + renamingInputRef.current = null; + }, []); + const commitRename = useCallback(async () => { + const finishRename = () => { + setIsRenaming(false); + renamingCommittedRef.current = false; + renamingInputRef.current = null; + }; + + const trimmed = renamingTitle.trim(); + if (trimmed.length === 0) { + toastManager.add({ + type: "warning", + title: "Thread title cannot be empty", + }); + finishRename(); + return; + } + if (trimmed === thread.title) { + finishRename(); + return; + } + const api = readEnvironmentApi(threadRef.environmentId); + if (!api) { + finishRename(); + return; + } + try { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: threadRef.threadId, + title: trimmed, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + finishRename(); + }, [renamingTitle, thread.title, threadRef]); + const handleMultiSelectContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; + if (threadKeys.length === 0) return; + const count = threadKeys.length; + + const clicked = await api.contextMenu.show( + [ + { id: "mark-unread", label: `Mark unread (${count})` }, + { id: "delete", label: `Delete (${count})`, destructive: true }, + ], + position, + ); + + if (clicked === "mark-unread") { + const appState = useStore.getState(); + for (const selectedThreadKey of threadKeys) { + const selectedThreadRef = parseScopedThreadKey(selectedThreadKey); + if (!selectedThreadRef) continue; + const selectedThread = selectSidebarThreadSummaryByRef(appState, selectedThreadRef); + markThreadUnread(selectedThreadKey, selectedThread?.latestTurn?.completedAt); + } + clearSelection(); + return; + } + + if (clicked !== "delete") return; + + if (appSettingsConfirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete ${count} thread${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ); + if (!confirmed) return; + } + + const deletedThreadKeys = new Set(threadKeys); + for (const selectedThreadKey of threadKeys) { + const selectedThreadRef = parseScopedThreadKey(selectedThreadKey); + if (!selectedThreadRef) continue; + await deleteThread(selectedThreadRef, { deletedThreadKeys }); + } + removeFromSelection(threadKeys); + }, + [ + appSettingsConfirmThreadDelete, + clearSelection, + deleteThread, + markThreadUnread, + removeFromSelection, + ], + ); + const handleThreadContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const threadWorkspacePath = thread.worktreePath ?? project?.cwd ?? null; + const currentThreadSummary = selectSidebarThreadSummaryByRef(useStore.getState(), threadRef); + const clicked = await api.contextMenu.show( + [ + { id: "rename", label: "Rename thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "copy-path", label: "Copy Path" }, + { id: "copy-thread-id", label: "Copy Thread ID" }, + { id: "delete", label: "Delete", destructive: true }, + ], + position, + ); + + if (clicked === "rename") { + setIsRenaming(true); + setRenamingTitle(thread.title); + renamingCommittedRef.current = false; + return; + } + + if (clicked === "mark-unread") { + markThreadUnread(threadKey, currentThreadSummary?.latestTurn?.completedAt); + return; + } + if (clicked === "copy-path") { + if (!threadWorkspacePath) { + toastManager.add({ + type: "error", + title: "Path unavailable", + description: "This thread does not have a workspace path to copy.", + }); + return; + } + copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); + return; + } + if (clicked === "copy-thread-id") { + copyThreadIdToClipboard(thread.id, { threadId: thread.id }); + return; + } + if (clicked !== "delete") return; + if (appSettingsConfirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ); + if (!confirmed) { + return; + } + } + await deleteThread(threadRef); + }, + [ + appSettingsConfirmThreadDelete, + copyPathToClipboard, + copyThreadIdToClipboard, + deleteThread, + markThreadUnread, + project?.cwd, + thread, + threadKey, + threadRef, + ], + ); + const handleRowClick = useCallback( + (event: React.MouseEvent) => { + const isMac = isMacPlatform(navigator.platform); + const isModClick = isMac ? event.metaKey : event.ctrlKey; + const isShiftClick = event.shiftKey; + const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size; + + if (isModClick) { + event.preventDefault(); + toggleThreadSelection(threadKey); + return; + } + + if (isShiftClick) { + event.preventDefault(); + const orderedProjectThreadKeys = project + ? sortThreadsForSidebar( + selectSidebarThreadsForProjectRefs( + useStore.getState(), + project.memberProjectRefs, + ).filter((projectThread) => projectThread.archivedAt === null), + threadSortOrder, + ).map((projectThread) => + scopedThreadKey(scopeThreadRef(projectThread.environmentId, projectThread.id)), + ) + : [threadKey]; + rangeSelectTo(threadKey, orderedProjectThreadKeys); + return; + } + + if (currentSelectionCount > 0) { + clearSelection(); + } + navigateToThread(threadRef); + }, + [ + clearSelection, + navigateToThread, + project, + rangeSelectTo, + threadKey, + threadRef, + threadSortOrder, + toggleThreadSelection, + ], + ); + const handleRowKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + navigateToThread(threadRef); + }, + [navigateToThread, threadRef], + ); + const handleRowContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + if (hasSelection && isSelected) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + return; + } + + if (hasSelection) { + clearSelection(); + } + void handleThreadContextMenu({ + x: event.clientX, + y: event.clientY, + }); + }, + [ + clearSelection, + handleMultiSelectContextMenu, + handleThreadContextMenu, + hasSelection, + isSelected, + ], + ); + const handlePrClick = useCallback( + (event: React.MouseEvent) => { + if (!prStatus) return; + openPrLink(event, prStatus.url); + }, + [openPrLink, prStatus], + ); + const handleRenameInputRef = useCallback( + (element: HTMLInputElement | null) => { + if (element && renamingInputRef.current !== element) { + renamingInputRef.current = element; + element.focus(); + element.select(); + } + }, + [renamingInputRef], + ); + const handleRenameInputChange = useCallback( + (event: React.ChangeEvent) => { + setRenamingTitle(event.target.value); + }, + [setRenamingTitle], + ); + const handleRenameInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(); + } else if (event.key === "Escape") { + event.preventDefault(); renamingCommittedRef.current = true; cancelRename(); } }, - [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], + [cancelRename, commitRename, renamingCommittedRef], ); const handleRenameInputBlur = useCallback(() => { if (!renamingCommittedRef.current) { - void commitRename(threadRef, renamingTitle, thread.title); + void commitRename(); } - }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); + }, [commitRename, renamingCommittedRef]); const handleRenameInputClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); }, []); - const handleConfirmArchiveRef = useCallback( - (element: HTMLButtonElement | null) => { - if (element) { - confirmArchiveButtonRefs.current.set(threadKey, element); - } else { - confirmArchiveButtonRefs.current.delete(threadKey); - } - }, - [confirmArchiveButtonRefs, threadKey], - ); const stopPropagationOnPointerDown = useCallback( (event: React.PointerEvent) => { event.stopPropagation(); @@ -572,28 +988,28 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP event.preventDefault(); event.stopPropagation(); clearConfirmingArchive(); - void attemptArchiveThread(threadRef); + void attemptArchiveThread(); }, - [attemptArchiveThread, clearConfirmingArchive, threadRef], + [attemptArchiveThread, clearConfirmingArchive], ); const handleStartArchiveConfirmation = useCallback( (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); - setConfirmingArchiveThreadKey(threadKey); + setIsConfirmingArchive(true); requestAnimationFrame(() => { - confirmArchiveButtonRefs.current.get(threadKey)?.focus(); + confirmArchiveButtonRef.current?.focus(); }); }, - [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey], + [], ); const handleArchiveImmediateClick = useCallback( (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); - void attemptArchiveThread(threadRef); + void attemptArchiveThread(); }, - [attemptArchiveThread, threadRef], + [attemptArchiveThread], ); const rowButtonRender = useMemo(() =>
, []); @@ -635,8 +1051,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP {prStatus.tooltip} )} - {threadStatus && } - {renamingThreadKey === threadKey ? ( + + {isRenaming ? (
- {terminalStatus && ( - - - - )} +
- {isConfirmingArchive ? ( - - ) : !isThreadRunning ? ( - appSettingsConfirmThreadArchive ? ( -
- -
- ) : ( - - - -
- } - /> - Archive - - ) - ) : null} - - - {isRemoteThread && ( - - - } - > - - - {threadEnvironmentLabel} - - )} - {jumpLabel ? ( - - {jumpLabel} - - ) : ( - - {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} - - )} - - +
@@ -758,89 +1093,30 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }); interface SidebarProjectThreadListProps { - projectKey: string; + project: SidebarProjectSnapshot; projectExpanded: boolean; hasOverflowingThreads: boolean; - hiddenThreadStatus: ThreadStatusPill | null; - orderedProjectThreadKeys: readonly string[]; - renderedThreads: readonly SidebarThreadSummary[]; + hiddenThreadKeys: readonly string[]; + renderedThreadKeys: readonly string[]; showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; - projectCwd: string; - activeRouteThreadKey: string | null; - threadJumpLabelByKey: ReadonlyMap; - appSettingsConfirmThreadArchive: boolean; - renamingThreadKey: string | null; - renamingTitle: string; - setRenamingTitle: (title: string) => void; - renamingInputRef: React.RefObject; - renamingCommittedRef: React.RefObject; - confirmingArchiveThreadKey: string | null; - setConfirmingArchiveThreadKey: React.Dispatch>; - confirmArchiveButtonRefs: React.RefObject>; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - handleThreadClick: ( - event: React.MouseEvent, - threadRef: ScopedThreadRef, - orderedProjectThreadKeys: readonly string[], - ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => void; - handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; - handleThreadContextMenu: ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => Promise; - clearSelection: () => void; - commitRename: ( - threadRef: ScopedThreadRef, - newTitle: string, - originalTitle: string, - ) => Promise; - cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: (event: React.MouseEvent, prUrl: string) => void; - expandThreadListForProject: (projectKey: string) => void; - collapseThreadListForProject: (projectKey: string) => void; } const SidebarProjectThreadList = memo(function SidebarProjectThreadList( props: SidebarProjectThreadListProps, ) { const { - projectKey, + project, projectExpanded, hasOverflowingThreads, - hiddenThreadStatus, - orderedProjectThreadKeys, - renderedThreads, + hiddenThreadKeys, + renderedThreadKeys, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, - projectCwd, - activeRouteThreadKey, - threadJumpLabelByKey, - appSettingsConfirmThreadArchive, - renamingThreadKey, - renamingTitle, - setRenamingTitle, - renamingInputRef, - renamingCommittedRef, - confirmingArchiveThreadKey, - setConfirmingArchiveThreadKey, - confirmArchiveButtonRefs, attachThreadListAutoAnimateRef, - handleThreadClick, - navigateToThread, - handleMultiSelectContextMenu, - handleThreadContextMenu, - clearSelection, - commitRename, - cancelRename, - attemptArchiveThread, - openPrLink, - expandThreadListForProject, - collapseThreadListForProject, } = props; const showMoreButtonRender = useMemo(() => -
+ } - /> + > + + - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + Remote environment: {project.remoteEnvironmentLabels.join(", ")} -
- - + + +
+ } + /> + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + +
+ ); +}); + +interface SidebarProjectItemProps { + project: SidebarProjectSnapshot; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + dragInProgressRef: React.RefObject; + suppressProjectClickAfterDragRef: React.RefObject; + suppressProjectClickForContextMenuRef: React.RefObject; + isManualProjectSorting: boolean; + dragHandleProps: SortableProjectHandleProps | null; +} + +const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { + const { + project, + attachThreadListAutoAnimateRef, + dragInProgressRef, + suppressProjectClickAfterDragRef, + suppressProjectClickForContextMenuRef, + isManualProjectSorting, + dragHandleProps, + } = props; + + return ( + <> + + + ); @@ -2008,6 +1969,8 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { }); interface SidebarProjectsContentProps { + sortedProjectKeys: readonly LogicalProjectKey[]; + sidebarProjectByKey: ReadonlyMap; showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; desktopUpdateButtonAction: "download" | "install" | "none"; @@ -2036,19 +1999,7 @@ interface SidebarProjectsContentProps { handleProjectDragStart: (event: DragStartEvent) => void; handleProjectDragEnd: (event: DragEndEvent) => void; handleProjectDragCancel: (event: DragCancelEvent) => void; - handleNewThread: ReturnType["handleNewThread"]; - archiveThread: ReturnType["archiveThread"]; - deleteThread: ReturnType["deleteThread"]; - sortedProjects: readonly SidebarProjectSnapshot[]; - expandedThreadListsByProject: ReadonlySet; - activeRouteProjectKey: string | null; - routeThreadKey: string | null; - newThreadShortcutLabel: string | null; - commandPaletteShortcutLabel: string | null; - threadJumpLabelByKey: ReadonlyMap; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - expandThreadListForProject: (projectKey: string) => void; - collapseThreadListForProject: (projectKey: string) => void; dragInProgressRef: React.RefObject; suppressProjectClickAfterDragRef: React.RefObject; suppressProjectClickForContextMenuRef: React.RefObject; @@ -2060,6 +2011,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( props: SidebarProjectsContentProps, ) { const { + sortedProjectKeys, + sidebarProjectByKey, showArm64IntelBuildWarning, arm64IntelBuildWarningDescription, desktopUpdateButtonAction, @@ -2088,26 +2041,26 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleProjectDragStart, handleProjectDragEnd, handleProjectDragCancel, - handleNewThread, - archiveThread, - deleteThread, - sortedProjects, - expandedThreadListsByProject, - activeRouteProjectKey, - routeThreadKey, - newThreadShortcutLabel, - commandPaletteShortcutLabel, - threadJumpLabelByKey, attachThreadListAutoAnimateRef, - expandThreadListForProject, - collapseThreadListForProject, dragInProgressRef, suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef, attachProjectListAutoAnimateRef, projectsLength, } = props; - + const keybindings = useServerKeybindings(); + const platform = navigator.platform; + const commandPaletteShortcutLabel = shortcutLabelForCommand( + keybindings, + "commandPalette.toggle", + { + platform, + context: { + terminalFocus: false, + terminalOpen: false, + }, + }, + ); const handleProjectSortOrderChange = useCallback( (sortOrder: SidebarProjectSortOrder) => { updateSettings({ sidebarProjectSortOrder: sortOrder }); @@ -2280,65 +2233,59 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( > project.projectKey)} + items={[...sortedProjectKeys]} strategy={verticalListSortingStrategy} > - {sortedProjects.map((project) => ( - - {(dragHandleProps) => ( - - )} - - ))} + {sortedProjectKeys.map((projectKey) => + (() => { + const project = sidebarProjectByKey.get(projectKey); + if (!project) { + return null; + } + return ( + + {(dragHandleProps) => ( + + )} + + ); + })(), + )} ) : ( - {sortedProjects.map((project) => ( - + (() => { + const project = sidebarProjectByKey.get(projectKey); + if (!project) { + return null; } - newThreadShortcutLabel={newThreadShortcutLabel} - handleNewThread={handleNewThread} - archiveThread={archiveThread} - deleteThread={deleteThread} - threadJumpLabelByKey={threadJumpLabelByKey} - attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} - expandThreadListForProject={expandThreadListForProject} - collapseThreadListForProject={collapseThreadListForProject} - dragInProgressRef={dragInProgressRef} - suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef} - suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} - isManualProjectSorting={isManualProjectSorting} - dragHandleProps={null} - /> - ))} + return ( + + ); + })(), + )} )} @@ -2354,45 +2301,32 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); - const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); const navigate = useNavigate(); - const pathname = useLocation({ select: (loc) => loc.pathname }); - const isOnSettings = pathname.startsWith("/settings"); + const isOnSettings = useLocation({ select: (loc) => loc.pathname.startsWith("/settings") }); + const settingsPathname = useLocation({ + select: (loc) => (loc.pathname.startsWith("/settings") ? loc.pathname : "/settings"), + }); const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); - const { archiveThread, deleteThread } = useThreadActions(); - const routeThreadRef = useParams({ - strict: false, - select: (params) => resolveThreadRouteRef(params), - }); - const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const keybindings = useServerKeybindings(); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); const addProjectInputRef = useRef(null); - const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< - ReadonlySet - >(() => new Set()); - const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); - const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); - const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -2406,81 +2340,25 @@ export default function Sidebar() { }); }, [projectOrder, projects]); - // Build a mapping from physical project key → logical project key for - // cross-environment grouping. Projects that share a repositoryIdentity - // canonicalKey are treated as one logical project in the sidebar. - const physicalToLogicalKey = useMemo(() => { - const mapping = new Map(); - for (const project of orderedProjects) { - const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); - mapping.set(physicalKey, deriveLogicalProjectKey(project)); - } - return mapping; - }, [orderedProjects]); + const physicalToLogicalKey = useMemo( + () => buildSidebarPhysicalToLogicalKeyMap(orderedProjects), + [orderedProjects], + ); + const previousSidebarProjectSnapshotByKeyRef = useRef< + ReadonlyMap + >(new Map()); const sidebarProjects = useMemo(() => { - // Group projects by logical key while preserving insertion order from - // orderedProjects. - const groupedMembers = new Map(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - const existing = groupedMembers.get(logicalKey); - if (existing) { - existing.push(project); - } else { - groupedMembers.set(logicalKey, [project]); - } - } - - const result: SidebarProjectSnapshot[] = []; - const seen = new Set(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - if (seen.has(logicalKey)) continue; - seen.add(logicalKey); - - const members = groupedMembers.get(logicalKey)!; - // Prefer the primary environment's project as the representative. - const representative: Project | undefined = - (primaryEnvironmentId - ? members.find((p) => p.environmentId === primaryEnvironmentId) - : undefined) ?? members[0]; - if (!representative) continue; - const hasLocal = - primaryEnvironmentId !== null && - members.some((p) => p.environmentId === primaryEnvironmentId); - const hasRemote = - primaryEnvironmentId !== null - ? members.some((p) => p.environmentId !== primaryEnvironmentId) - : false; - - const refs = members.map((p) => scopeProjectRef(p.environmentId, p.id)); - const remoteLabels = members - .filter((p) => primaryEnvironmentId !== null && p.environmentId !== primaryEnvironmentId) - .map((p) => { - const rt = savedEnvironmentRuntimeById[p.environmentId]; - const saved = savedEnvironmentRegistry[p.environmentId]; - return rt?.descriptor?.label ?? saved?.label ?? p.environmentId; - }); - const snapshot: SidebarProjectSnapshot = { - id: representative.id, - environmentId: representative.environmentId, - name: representative.name, - cwd: representative.cwd, - repositoryIdentity: representative.repositoryIdentity ?? null, - defaultModelSelection: representative.defaultModelSelection, - createdAt: representative.createdAt, - updatedAt: representative.updatedAt, - scripts: representative.scripts, - projectKey: logicalKey, - environmentPresence: - hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", - memberProjectRefs: refs, - remoteEnvironmentLabels: remoteLabels, - }; - result.push(snapshot); - } - return result; + const { projectSnapshotByKey, sidebarProjects: nextSidebarProjects } = + buildSidebarProjectSnapshots({ + orderedProjects, + previousProjectSnapshotByKey: previousSidebarProjectSnapshotByKeyRef.current, + primaryEnvironmentId, + savedEnvironmentRegistryById: savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + }); + previousSidebarProjectSnapshotByKeyRef.current = projectSnapshotByKey; + return [...nextSidebarProjects]; }, [ orderedProjects, primaryEnvironmentId, @@ -2489,82 +2367,36 @@ export default function Sidebar() { ]); const sidebarProjectByKey = useMemo( - () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), - [sidebarProjects], - ); - const sidebarThreadByKey = useMemo( () => - new Map( - sidebarThreads.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), + new Map( + sidebarProjects.map((project) => [project.projectKey, project] as const), ), - [sidebarThreads], - ); - // Resolve the active route's project key to a logical key so it matches the - // sidebar's grouped project entries. - const activeRouteProjectKey = useMemo(() => { - if (!routeThreadKey) { - return null; - } - const activeThread = sidebarThreadByKey.get(routeThreadKey); - if (!activeThread) return null; - const physicalKey = scopedProjectKey( - scopeProjectRef(activeThread.environmentId, activeThread.projectId), - ); - return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]); - - // Group threads by logical project key so all threads from grouped projects - // are displayed together. - const threadsByProjectKey = useMemo(() => { - const next = new Map(); - for (const thread of sidebarThreads) { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const existing = next.get(logicalKey); - if (existing) { - existing.push(thread); - } else { - next.set(logicalKey, [thread]); - } - } - return next; - }, [sidebarThreads, physicalToLogicalKey]); - const getCurrentSidebarShortcutContext = useCallback( - () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen: routeThreadRef - ? selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, - }), - [routeThreadRef], + [sidebarProjects], ); - const newThreadShortcutLabelOptions = useMemo( - () => ({ - platform, - context: { - terminalFocus: false, - terminalOpen: false, - }, - }), - [platform], + const sortedProjectKeys = useStore( + useMemo( + () => + createSidebarSortedProjectKeysSelector({ + physicalToLogicalKey, + projects: sidebarProjects, + sortOrder: sidebarProjectSortOrder, + }), + [physicalToLogicalKey, sidebarProjectSortOrder, sidebarProjects], + ), ); - const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? - shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); const focusMostRecentThreadForProject = useCallback( (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { const physicalKey = scopedProjectKey( scopeProjectRef(projectRef.environmentId, projectRef.projectId), ); const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const latestThread = sortThreads( - (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null), + const memberProjectRefs = sidebarProjectByKey.get(logicalKey)?.memberProjectRefs ?? [ + projectRef, + ]; + const latestThread = sortThreadsForSidebar( + selectSidebarThreadsForProjectRefs(useStore.getState(), memberProjectRefs).filter( + (thread) => thread.archivedAt === null, + ), sidebarThreadSortOrder, )[0]; if (!latestThread) return; @@ -2574,7 +2406,7 @@ export default function Sidebar() { params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), }); }, - [sidebarThreadSortOrder, navigate, threadsByProjectKey, physicalToLogicalKey], + [navigate, physicalToLogicalKey, sidebarProjectByKey, sidebarThreadSortOrder], ); const addProjectFromInput = useCallback( @@ -2763,268 +2595,13 @@ export default function Sidebar() { animatedThreadListsRef.current.add(node); }, []); - const visibleThreads = useMemo( - () => sidebarThreads.filter((thread) => thread.archivedAt === null), - [sidebarThreads], - ); - const sortedProjects = useMemo(() => { - const sortableProjects = sidebarProjects.map((project) => ({ - ...project, - id: project.projectKey, - })); - const sortableThreads = visibleThreads.map((thread) => { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - return { - ...thread, - projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, - }; - }); - return sortProjectsForSidebar( - sortableProjects, - sortableThreads, - sidebarProjectSortOrder, - ).flatMap((project) => { - const resolvedProject = sidebarProjectByKey.get(project.id); - return resolvedProject ? [resolvedProject] : []; - }); - }, [ - sidebarProjectSortOrder, - physicalToLogicalKey, - sidebarProjectByKey, - sidebarProjects, - visibleThreads, - ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; - const visibleSidebarThreadKeys = useMemo( - () => - sortedProjects.flatMap((project) => { - const projectThreads = sortThreads( - (threadsByProjectKey.get(project.projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, - ), - sidebarThreadSortOrder, - ); - const projectExpanded = projectExpandedById[project.projectKey] ?? true; - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - activeThreadKey, - ) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return []; - } - const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - }), - [ - sidebarThreadSortOrder, - expandedThreadListsByProject, - projectExpandedById, - routeThreadKey, - sortedProjects, - threadsByProjectKey, - ], - ); - const threadJumpCommandByKey = useMemo(() => { - const mapping = new Map>>(); - for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { - const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); - if (!jumpCommand) { - return mapping; - } - mapping.set(threadKey, jumpCommand); - } - - return mapping; - }, [visibleSidebarThreadKeys]); - const threadJumpThreadKeys = useMemo( - () => [...threadJumpCommandByKey.keys()], - [threadJumpCommandByKey], - ); - const [threadJumpLabelByKey, setThreadJumpLabelByKey] = - useState>(EMPTY_THREAD_JUMP_LABELS); - const threadJumpLabelsRef = useRef>(EMPTY_THREAD_JUMP_LABELS); - threadJumpLabelsRef.current = threadJumpLabelByKey; - const showThreadJumpHintsRef = useRef(showThreadJumpHints); - showThreadJumpHintsRef.current = showThreadJumpHints; - const visibleThreadJumpLabelByKey = showThreadJumpHints - ? threadJumpLabelByKey - : EMPTY_THREAD_JUMP_LABELS; - const orderedSidebarThreadKeys = visibleSidebarThreadKeys; - - useEffect(() => { - const clearThreadJumpHints = () => { - setThreadJumpLabelByKey((current) => - current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, - ); - updateThreadJumpHintsVisibility(false); - }; - const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - event.key !== "Meta" && - event.key !== "Control" && - event.key !== "Alt" && - event.key !== "Shift" && - !showThreadJumpHintsRef.current && - threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; - - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - if ( - showThreadJumpHintsRef.current || - threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS - ) { - clearThreadJumpHints(); - } - } else { - setThreadJumpLabelByKey((current) => { - const nextLabelMap = buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: shortcutContext.terminalOpen, - threadJumpCommandByKey, - }); - return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; - }); - updateThreadJumpHintsVisibility(true); - } - - if (event.defaultPrevented || event.repeat) { - return; - } - - const command = resolveShortcutCommand(event, keybindings, { - platform, - context: shortcutContext, - }); - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - const targetThreadKey = resolveAdjacentThreadId({ - threadIds: orderedSidebarThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }); - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - return; - } - - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } - - const targetThreadKey = threadJumpThreadKeys[jumpIndex]; - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - }; - - const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - clearThreadJumpHints(); - return; - } - setThreadJumpLabelByKey((current) => { - const nextLabelMap = buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: shortcutContext.terminalOpen, - threadJumpCommandByKey, - }); - return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; - }); - updateThreadJumpHintsVisibility(true); - }; - - const onWindowBlur = () => { - clearThreadJumpHints(); - }; - - window.addEventListener("keydown", onWindowKeyDown); - window.addEventListener("keyup", onWindowKeyUp); - window.addEventListener("blur", onWindowBlur); - - return () => { - window.removeEventListener("keydown", onWindowKeyDown); - window.removeEventListener("keyup", onWindowKeyUp); - window.removeEventListener("blur", onWindowBlur); - }; - }, [ - getCurrentSidebarShortcutContext, - keybindings, - navigateToThread, - orderedSidebarThreadKeys, - platform, - routeThreadKey, - sidebarThreadByKey, - threadJumpCommandByKey, - threadJumpThreadKeys, - updateThreadJumpHintsVisibility, - ]); useEffect(() => { - const onMouseDown = (event: globalThis.MouseEvent) => { - if (selectedThreadCount === 0) return; - const target = event.target instanceof HTMLElement ? event.target : null; - if (!shouldClearThreadSelectionOnMouseDown(target)) return; - clearSelection(); - }; - - window.addEventListener("mousedown", onMouseDown); return () => { - window.removeEventListener("mousedown", onMouseDown); + resetSidebarViewState(); }; - }, [clearSelection, selectedThreadCount]); + }, []); useEffect(() => { if (!isElectron) return; @@ -3069,11 +2646,6 @@ export default function Sidebar() { desktopUpdateState && showArm64IntelBuildWarning ? getArm64IntelBuildWarningDescription(desktopUpdateState) : null; - const commandPaletteShortcutLabel = shortcutLabelForCommand( - keybindings, - "commandPalette.toggle", - newThreadShortcutLabelOptions, - ); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; @@ -3136,33 +2708,24 @@ export default function Sidebar() { } }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); - const expandThreadListForProject = useCallback((projectKey: string) => { - setExpandedThreadListsByProject((current) => { - if (current.has(projectKey)) return current; - const next = new Set(current); - next.add(projectKey); - return next; - }); - }, []); - - const collapseThreadListForProject = useCallback((projectKey: string) => { - setExpandedThreadListsByProject((current) => { - if (!current.has(projectKey)) return current; - const next = new Set(current); - next.delete(projectKey); - return next; - }); - }, []); - return ( <> + + {isOnSettings ? ( - + ) : ( <> ; + routeKind: "server" | "draft"; + routeThreadRef: ScopedThreadRef; + draftThreadActivities: Thread["activities"]; isPreparingWorktree: boolean; pendingAction: { questionIndex: number; @@ -277,7 +281,11 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( }) { return ( <> - {props.activeContextWindow ? : null} + {props.isPreparingWorktree ? ( Preparing worktree... ) : null} @@ -299,6 +307,32 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( ); }); +const ComposerContextWindowMeterContainer = memo( + function ComposerContextWindowMeterContainer(props: { + routeKind: "server" | "draft"; + routeThreadRef: ScopedThreadRef; + draftThreadActivities: Thread["activities"]; + }) { + const serverThreadActivities = useStore( + useMemo( + () => + createThreadActivitiesSelectorByRef( + props.routeKind === "server" ? props.routeThreadRef : null, + ), + [props.routeKind, props.routeThreadRef], + ), + ); + const threadActivities = + props.routeKind === "server" ? serverThreadActivities : props.draftThreadActivities; + const activeContextWindow = useMemo( + () => deriveLatestContextWindowSnapshot(threadActivities), + [threadActivities], + ); + + return activeContextWindow ? : null; + }, +); + // -------------------------------------------------------------------------- // Handle exposed to ChatView // -------------------------------------------------------------------------- @@ -348,7 +382,6 @@ export interface ChatComposerProps { // Thread context activeThreadId: ThreadId | null; activeThreadEnvironmentId: EnvironmentId | undefined; - activeThread: Thread | undefined; isServerThread: boolean; isLocalDraftThread: boolean; @@ -391,9 +424,7 @@ export interface ChatComposerProps { providerStatuses: ServerProvider[]; activeProjectDefaultModelSelection: ModelSelection | null | undefined; activeThreadModelSelection: ModelSelection | null | undefined; - - // Context window - activeThreadActivities: Thread["activities"] | undefined; + draftThreadActivities: Thread["activities"]; // Misc resolvedTheme: "light" | "dark"; @@ -454,7 +485,6 @@ export const ChatComposer = memo( draftId, activeThreadId, activeThreadEnvironmentId: _activeThreadEnvironmentId, - activeThread, isServerThread: _isServerThread, isLocalDraftThread: _isLocalDraftThread, phase, @@ -481,7 +511,7 @@ export const ChatComposer = memo( providerStatuses, activeProjectDefaultModelSelection, activeThreadModelSelection, - activeThreadActivities, + draftThreadActivities, resolvedTheme, settings, gitCwd, @@ -627,11 +657,6 @@ export const ChatComposer = memo( // ------------------------------------------------------------------ // Context window // ------------------------------------------------------------------ - const activeContextWindow = useMemo( - () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), - [activeThreadActivities], - ); - // ------------------------------------------------------------------ // Composer-local state // ------------------------------------------------------------------ @@ -1639,7 +1664,7 @@ export const ChatComposer = memo( ); }, addTerminalContext: (selection: TerminalContextSelection) => { - if (!activeThread) return; + if (!activeThreadId) return; const snapshot = composerEditorRef.current?.readSnapshot() ?? { value: promptRef.current, cursor: composerCursor, @@ -1659,7 +1684,7 @@ export const ChatComposer = memo( insertion.prompt, { id: randomUUID(), - threadId: activeThread.id, + threadId: activeThreadId, createdAt: new Date().toISOString(), ...selection, }, @@ -1686,7 +1711,7 @@ export const ChatComposer = memo( }), }), [ - activeThread, + activeThreadId, composerDraftTarget, composerCursor, composerTerminalContexts, @@ -1964,7 +1989,9 @@ export const ChatComposer = memo( > { activeTurnInProgress={false} activeTurnStartedAt={null} scrollContainer={null} - timelineEntries={[ + historicalTimelineEntries={[ { id: "entry-1", kind: "message", @@ -76,6 +76,7 @@ describe("MessagesTimeline", () => { }, }, ]} + liveTimelineEntries={[]} completionDividerBeforeEntryId={null} completionSummary={null} turnDiffSummaryByAssistantMessageId={new Map()} @@ -100,7 +101,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Terminal 1 lines 1-5"); expect(markup).toContain("lucide-terminal"); expect(markup).toContain("yoo what's "); - }, 10_000); + }, 20_000); it("renders context compaction entries in the normal work log", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); @@ -111,7 +112,7 @@ describe("MessagesTimeline", () => { activeTurnInProgress={false} activeTurnStartedAt={null} scrollContainer={null} - timelineEntries={[ + historicalTimelineEntries={[ { id: "entry-1", kind: "work", @@ -124,6 +125,7 @@ describe("MessagesTimeline", () => { }, }, ]} + liveTimelineEntries={[]} completionDividerBeforeEntryId={null} completionSummary={null} turnDiffSummaryByAssistantMessageId={new Map()} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f08d544cc1..005631ee1d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -73,11 +73,12 @@ interface MessagesTimelineProps { activeTurnId?: TurnId | null; activeTurnStartedAt: string | null; scrollContainer: HTMLDivElement | null; - timelineEntries: ReturnType; + historicalTimelineEntries: ReturnType; + liveTimelineEntries: ReturnType; completionDividerBeforeEntryId: string | null; completionSummary: string | null; turnDiffSummaryByAssistantMessageId: Map; - nowIso: string; + nowIso?: string; expandedWorkGroups: Record; onToggleWorkGroup: (groupId: string) => void; changedFilesExpandedByTurnId: Record; @@ -105,18 +106,18 @@ interface MessagesTimelineProps { }) => void; } -export const MessagesTimeline = memo(function MessagesTimeline({ - hasMessages, - isWorking, - activeTurnInProgress, - activeTurnId, - activeTurnStartedAt, +export const TimelineEmptyState = memo(function TimelineEmptyState() { + return ( +
+

Send a message to start the conversation.

+
+ ); +}); + +export const HistoricalMessagesTimelineSection = memo(function HistoricalMessagesTimelineSection({ scrollContainer, - timelineEntries, - completionDividerBeforeEntryId, - completionSummary, + historicalTimelineEntries, turnDiffSummaryByAssistantMessageId, - nowIso, expandedWorkGroups, onToggleWorkGroup, changedFilesExpandedByTurnId, @@ -132,7 +133,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timestampFormat, workspaceRoot, onVirtualizerSnapshot, -}: MessagesTimelineProps) { +}: Pick< + MessagesTimelineProps, + | "scrollContainer" + | "historicalTimelineEntries" + | "turnDiffSummaryByAssistantMessageId" + | "expandedWorkGroups" + | "onToggleWorkGroup" + | "changedFilesExpandedByTurnId" + | "onSetChangedFilesExpanded" + | "onOpenTurnDiff" + | "revertTurnCountByUserMessageId" + | "onRevertUserMessage" + | "isRevertingCheckpoint" + | "onImageExpand" + | "activeThreadEnvironmentId" + | "markdownCwd" + | "resolvedTheme" + | "timestampFormat" + | "workspaceRoot" + | "onVirtualizerSnapshot" +>) { const timelineRootRef = useRef(null); const [timelineWidthPx, setTimelineWidthPx] = useState(null); @@ -159,60 +180,29 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return () => { observer.disconnect(); }; - }, [hasMessages, isWorking]); + }, []); - const rows = useMemo( + const historicalRawRows = useMemo( () => deriveMessagesTimelineRows({ - timelineEntries, - completionDividerBeforeEntryId, - isWorking, - activeTurnStartedAt, + timelineEntries: historicalTimelineEntries, + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, }), - [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt], + [historicalTimelineEntries], ); + const historicalRows = useStableTimelineRows(historicalRawRows); + const shouldRenderHistoricalRowsWithoutVirtualizer = typeof ResizeObserver === "undefined"; + const firstUnvirtualizedHistoricalRowIndex = Math.max( + historicalRows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, + 0, + ); + const measuredHistoricalRowHeightsRef = useRef>({}); - const firstUnvirtualizedRowIndex = useMemo(() => { - const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); - if (!activeTurnInProgress) return firstTailRowIndex; - - const turnStartedAtMs = - typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN; - let firstCurrentTurnRowIndex = -1; - if (!Number.isNaN(turnStartedAtMs)) { - firstCurrentTurnRowIndex = rows.findIndex((row) => { - if (row.kind === "working") return true; - if (!row.createdAt) return false; - const rowCreatedAtMs = Date.parse(row.createdAt); - return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs; - }); - } - - if (firstCurrentTurnRowIndex < 0) { - firstCurrentTurnRowIndex = rows.findIndex( - (row) => row.kind === "message" && row.message.streaming, - ); - } - - if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex; - - for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) { - const previousRow = rows[index]; - if (!previousRow || previousRow.kind !== "message") continue; - if (previousRow.message.role === "user") { - return Math.min(index, firstTailRowIndex); - } - if (previousRow.message.role === "assistant" && !previousRow.message.streaming) { - break; - } - } - - return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex); - }, [activeTurnInProgress, activeTurnStartedAt, rows]); - - const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, { + const virtualizedRowCount = clamp(firstUnvirtualizedHistoricalRowIndex, { minimum: 0, - maximum: rows.length, + maximum: historicalRows.length, }); const virtualMeasurementScopeKey = timelineWidthPx === null ? "width:unknown" : `width:${Math.round(timelineWidthPx)}`; @@ -223,12 +213,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ // Scope cached row measurements to the current timeline width so offscreen // rows do not keep stale heights after wrapping changes. getItemKey: (index: number) => { - const rowId = rows[index]?.id ?? String(index); + const rowId = historicalRows[index]?.id ?? String(index); return `${virtualMeasurementScopeKey}:${rowId}`; }, estimateSize: (index: number) => { - const row = rows[index]; + const row = historicalRows[index]; if (!row) return 96; + const measuredHeight = measuredHistoricalRowHeightsRef.current[row.id]; + if (typeof measuredHeight === "number" && measuredHeight > 0) { + return measuredHeight; + } return estimateMessagesTimelineRowHeight(row, { expandedWorkGroups, timelineWidthPx, @@ -284,7 +278,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ measurements: rowVirtualizer.measurementsCache .slice(0, virtualizedRowCount) .flatMap((measurement) => { - const row = rows[measurement.index]; + const row = historicalRows[measurement.index]; if (!row) { return []; } @@ -300,12 +294,505 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ]; }), }); - }, [onVirtualizerSnapshot, rowVirtualizer, rows, virtualizedRowCount]); + }, [historicalRows, onVirtualizerSnapshot, rowVirtualizer, virtualizedRowCount]); + + const virtualRows = useStableVirtualRows(rowVirtualizer.getVirtualItems()); + const nonVirtualizedRows = historicalRows.slice(virtualizedRowCount); + const onHistoricalRowHeightChange = useCallback((rowId: string, height: number) => { + const previousHeight = measuredHistoricalRowHeightsRef.current[rowId]; + if (typeof previousHeight === "number" && Math.abs(previousHeight - height) < 0.5) { + return; + } + measuredHistoricalRowHeightsRef.current[rowId] = height; + }, []); + + const renderHistoricalRowContent = useCallback( + (row: TimelineRow) => { + const turnDiffSummary = + row.kind === "message" && row.message.role === "assistant" + ? (turnDiffSummaryByAssistantMessageId.get(row.message.id) ?? null) + : null; + + return ( + + ); + }, + [ + activeThreadEnvironmentId, + changedFilesExpandedByTurnId, + expandedWorkGroups, + isRevertingCheckpoint, + markdownCwd, + onImageExpand, + onOpenTurnDiff, + onRevertUserMessage, + onSetChangedFilesExpanded, + onTimelineImageLoad, + onToggleWorkGroup, + resolvedTheme, + revertTurnCountByUserMessageId, + timestampFormat, + turnDiffSummaryByAssistantMessageId, + workspaceRoot, + ], + ); + + return ( +
+ {shouldRenderHistoricalRowsWithoutVirtualizer ? ( + + ) : virtualizedRowCount > 0 ? ( + <> + + + + ) : nonVirtualizedRows.length > 0 ? ( + + ) : null} +
+ ); +}); + +export const LiveMessagesTimelineSection = memo(function LiveMessagesTimelineSection({ + isWorking, + activeTurnInProgress, + activeTurnId, + activeTurnStartedAt, + liveTimelineEntries, + completionDividerBeforeEntryId, + completionSummary, + turnDiffSummaryByAssistantMessageId, + nowIso, + expandedWorkGroups, + onToggleWorkGroup, + changedFilesExpandedByTurnId, + onSetChangedFilesExpanded, + onOpenTurnDiff, + revertTurnCountByUserMessageId, + onRevertUserMessage, + isRevertingCheckpoint, + onImageExpand, + activeThreadEnvironmentId, + markdownCwd, + resolvedTheme, + timestampFormat, + workspaceRoot, +}: Pick< + MessagesTimelineProps, + | "isWorking" + | "activeTurnInProgress" + | "activeTurnId" + | "activeTurnStartedAt" + | "liveTimelineEntries" + | "completionDividerBeforeEntryId" + | "completionSummary" + | "turnDiffSummaryByAssistantMessageId" + | "nowIso" + | "expandedWorkGroups" + | "onToggleWorkGroup" + | "changedFilesExpandedByTurnId" + | "onSetChangedFilesExpanded" + | "onOpenTurnDiff" + | "revertTurnCountByUserMessageId" + | "onRevertUserMessage" + | "isRevertingCheckpoint" + | "onImageExpand" + | "activeThreadEnvironmentId" + | "markdownCwd" + | "resolvedTheme" + | "timestampFormat" + | "workspaceRoot" +>) { + const liveRawRows = useMemo( + () => + deriveMessagesTimelineRows({ + timelineEntries: liveTimelineEntries, + completionDividerBeforeEntryId, + isWorking, + activeTurnStartedAt, + }), + [activeTurnStartedAt, completionDividerBeforeEntryId, isWorking, liveTimelineEntries], + ); + const liveRows = useStableTimelineRows(liveRawRows); + + const renderLiveRowContent = useCallback( + (row: TimelineRow) => { + const turnDiffSummary = + row.kind === "message" && row.message.role === "assistant" + ? (turnDiffSummaryByAssistantMessageId.get(row.message.id) ?? null) + : null; + + return ( + {}} + /> + ); + }, + [ + activeThreadEnvironmentId, + activeTurnId, + activeTurnInProgress, + changedFilesExpandedByTurnId, + completionSummary, + expandedWorkGroups, + isRevertingCheckpoint, + isWorking, + markdownCwd, + nowIso, + onImageExpand, + onOpenTurnDiff, + onRevertUserMessage, + onSetChangedFilesExpanded, + onToggleWorkGroup, + resolvedTheme, + revertTurnCountByUserMessageId, + timestampFormat, + turnDiffSummaryByAssistantMessageId, + workspaceRoot, + ], + ); + + return ; +}); + +export const MessagesTimeline = memo(function MessagesTimeline(props: MessagesTimelineProps) { + if (!props.hasMessages && !props.isWorking) { + return ; + } + + return ( +
+ + +
+ ); +}); + +type TimelineEntry = ReturnType[number]; +type TimelineMessage = Extract["message"]; +type TimelineWorkEntry = Extract["groupedEntries"][number]; +type TimelineRow = MessagesTimelineRow; + +const HistoricalTimelineRows = memo(function HistoricalTimelineRows(props: { + rows: ReadonlyArray; + virtualRows: ReadonlyArray; + totalSize: number; + measureElement: (element: Element | null) => void; + renderRowContent: (row: TimelineRow) => ReactNode; +}) { + if (props.rows.length === 0) { + return null; + } + + return ( +
+ {props.virtualRows.map((virtualRow) => { + const row = props.rows[virtualRow.index]; + if (!row) return null; + + return ( +
+ {props.renderRowContent(row)} +
+ ); + })} +
+ ); +}); + +const LiveTimelineRows = memo(function LiveTimelineRows(props: { + rows: ReadonlyArray; + renderRowContent: (row: TimelineRow) => ReactNode; +}) { + return ; +}); + +const NonVirtualTimelineRows = memo(function NonVirtualTimelineRows(props: { + rows: ReadonlyArray; + renderRowContent: (row: TimelineRow) => ReactNode; + onRowHeightChange?: ((rowId: string, height: number) => void) | undefined; +}) { + return props.rows.map((row) => ( + + {props.renderRowContent(row)} + + )); +}); + +const MeasuredNonVirtualTimelineRow = memo(function MeasuredNonVirtualTimelineRow(props: { + rowId: string; + onHeightChange?: ((rowId: string, height: number) => void) | undefined; + children: ReactNode; +}) { + const { children, onHeightChange, rowId } = props; + const rowRef = useRef(null); + + useLayoutEffect(() => { + if (!onHeightChange) { + return; + } + + const rowElement = rowRef.current; + if (!rowElement) { + return; + } + + const emitHeight = () => { + onHeightChange(rowId, rowElement.getBoundingClientRect().height); + }; + + emitHeight(); + + if (typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver(() => { + emitHeight(); + }); + observer.observe(rowElement); + return () => { + observer.disconnect(); + }; + }, [onHeightChange, rowId]); + + return
{children}
; +}); + +function useStableTimelineRows(rows: ReadonlyArray): ReadonlyArray { + const previousRowsRef = useRef>([]); + const stabilizedRows = rows.map((row, index) => { + const previousRow = previousRowsRef.current[index]; + return canReuseTimelineRow(previousRow, row) ? previousRow : row; + }); + + previousRowsRef.current = stabilizedRows; + return stabilizedRows; +} + +function useStableVirtualRows(rows: ReadonlyArray): ReadonlyArray { + const previousRowsRef = useRef>([]); + const previousRows = previousRowsRef.current; + const hasSameRows = + previousRows.length === rows.length && + previousRows.every((row, index) => { + const candidate = rows[index]; + return ( + candidate !== undefined && + row.index === candidate.index && + row.start === candidate.start && + row.end === candidate.end && + row.size === candidate.size && + row.key === candidate.key + ); + }); + + if (hasSameRows) { + return previousRows; + } + + previousRowsRef.current = rows; + return rows; +} - const virtualRows = rowVirtualizer.getVirtualItems(); - const nonVirtualizedRows = rows.slice(virtualizedRowCount); +function canReuseTimelineRow( + previous: TimelineRow | undefined, + next: TimelineRow, +): previous is TimelineRow { + if (!previous || previous.kind !== next.kind || previous.id !== next.id) { + return false; + } + + switch (next.kind) { + case "message": { + const previousMessageRow = previous as Extract; + const nextMessageRow = next as Extract; + return ( + previousMessageRow.message === nextMessageRow.message && + previousMessageRow.durationStart === nextMessageRow.durationStart && + previousMessageRow.showCompletionDivider === nextMessageRow.showCompletionDivider && + previousMessageRow.showAssistantCopyButton === nextMessageRow.showAssistantCopyButton + ); + } + case "proposed-plan": { + const previousProposedPlanRow = previous as Extract; + const nextProposedPlanRow = next as Extract; + return previousProposedPlanRow.proposedPlan === nextProposedPlanRow.proposedPlan; + } + case "working": { + const previousWorkingRow = previous as Extract; + const nextWorkingRow = next as Extract; + return previousWorkingRow.createdAt === nextWorkingRow.createdAt; + } + case "work": { + const previousWorkRow = previous as Extract; + const nextWorkRow = next as Extract; + return workEntriesEqual(previousWorkRow.groupedEntries, nextWorkRow.groupedEntries); + } + } +} - const renderRowContent = (row: TimelineRow) => ( +interface TimelineRowItemProps { + row: TimelineRow; + completionSummary: string | null; + turnDiffSummary: TurnDiffSummary | null; + isExpandedWorkGroup: boolean; + changedFilesExpanded: boolean; + canRevertAgentWork: boolean; + activeTurnInProgress: boolean; + activeTurnId: TurnId | null | undefined; + isWorking: boolean; + isRevertingCheckpoint: boolean; + activeThreadEnvironmentId: EnvironmentId; + markdownCwd: string | undefined; + resolvedTheme: "light" | "dark"; + timestampFormat: TimestampFormat; + workspaceRoot: string | undefined; + nowIso: string | undefined; + onToggleWorkGroup: (groupId: string) => void; + onSetChangedFilesExpanded: (turnId: TurnId, expanded: boolean) => void; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onRevertUserMessage: (messageId: MessageId) => void; + onImageExpand: (preview: ExpandedImagePreview) => void; + onTimelineImageLoad: () => void; +} + +const TimelineRowItem = memo(function TimelineRowItem(props: TimelineRowItemProps) { + const { row } = props; + + return (
- {row.kind === "work" && - (() => { - const groupId = row.id; - const groupedEntries = row.groupedEntries; - const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleEntries = - hasOverflow && !isExpanded - ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : groupedEntries; - const hiddenCount = groupedEntries.length - visibleEntries.length; - const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); - const showHeader = hasOverflow || !onlyToolEntries; - const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; - - return ( -
- {showHeader && ( -
-

- {groupLabel} ({groupedEntries.length}) -

- {hasOverflow && ( - - )} -
- )} -
- {visibleEntries.map((workEntry) => ( - - ))} -
-
- ); - })()} - - {row.kind === "message" && - row.message.role === "user" && - (() => { - const userImages = row.message.attachments ?? []; - const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); - const terminalContexts = displayedUserMessage.contexts; - const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); - return ( -
-
- {userImages.length > 0 && ( -
- {userImages.map( - (image: NonNullable[number]) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} -
- ), - )} -
- )} - {(displayedUserMessage.visibleText.trim().length > 0 || - terminalContexts.length > 0) && ( - - )} -
-
- {displayedUserMessage.copyText && ( - - )} - {canRevertAgentWork && ( - - )} -
-

- {formatTimestamp(row.message.createdAt, timestampFormat)} -

-
-
-
- ); - })()} - - {row.kind === "message" && - row.message.role === "assistant" && - (() => { - const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); - const assistantTurnStillInProgress = - activeTurnInProgress && - activeTurnId !== null && - activeTurnId !== undefined && - row.message.turnId === activeTurnId; - const assistantCopyState = resolveAssistantMessageCopyState({ - text: row.message.text ?? null, - showCopyButton: row.showAssistantCopyButton, - streaming: row.message.streaming || assistantTurnStillInProgress, - }); - return ( - <> - {row.showCompletionDivider && ( -
- - - {completionSummary ? `Response • ${completionSummary}` : "Response"} - - -
- )} -
- - {(() => { - const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); - if (!turnSummary) return null; - const checkpointFiles = turnSummary.files; - if (checkpointFiles.length === 0) return null; - const summaryStat = summarizeTurnDiffStats(checkpointFiles); - const changedFileCountLabel = String(checkpointFiles.length); - const allDirectoriesExpanded = - changedFilesExpandedByTurnId[turnSummary.turnId] ?? true; - return ( -
-
-

- Changed files ({changedFileCountLabel}) - {hasNonZeroStat(summaryStat) && ( - <> - - - - )} -

-
- - -
-
- -
- ); - })()} -
-

- {formatMessageMeta( - row.message.createdAt, - row.message.streaming - ? formatElapsed(row.durationStart, nowIso) - : formatElapsed(row.durationStart, row.message.completedAt), - timestampFormat, - )} -

- {assistantCopyState.visible ? ( -
- -
- ) : null} -
-
- - ); - })()} + {row.kind === "work" ? ( + + ) : null} + + {row.kind === "message" && row.message.role === "user" ? ( + + ) : null} - {row.kind === "proposed-plan" && ( + {row.kind === "message" && row.message.role === "assistant" ? ( + + ) : null} + + {row.kind === "proposed-plan" ? (
- )} + ) : null} - {row.kind === "working" && ( + {row.kind === "working" ? (
-
- - - - - - - {row.createdAt - ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` - : "Working..."} - -
+
- )} + ) : null}
); +}); - if (!hasMessages && !isWorking) { - return ( -
-

- Send a message to start the conversation. -

-
- ); - } +const WorkGroupRow = memo(function WorkGroupRow(props: { + row: Extract; + isExpanded: boolean; + onToggleWorkGroup: (groupId: string) => void; +}) { + const groupedEntries = props.row.groupedEntries; + const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const visibleEntries = + hasOverflow && !props.isExpanded + ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) + : groupedEntries; + const hiddenCount = groupedEntries.length - visibleEntries.length; + const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); + const showHeader = hasOverflow || !onlyToolEntries; + const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; return ( -
- {virtualizedRowCount > 0 && ( -
- {virtualRows.map((virtualRow: VirtualItem) => { - const row = rows[virtualRow.index]; - if (!row) return null; +
+ {showHeader ? ( +
+

+ {groupLabel} ({groupedEntries.length}) +

+ {hasOverflow ? ( + + ) : null} +
+ ) : null} +
+ {visibleEntries.map((workEntry) => ( + + ))} +
+
+ ); +}); - return ( +const UserMessageRow = memo(function UserMessageRow(props: { + row: Extract; + canRevertAgentWork: boolean; + isRevertingCheckpoint: boolean; + isWorking: boolean; + onRevertUserMessage: (messageId: MessageId) => void; + onImageExpand: (preview: ExpandedImagePreview) => void; + onTimelineImageLoad: () => void; + timestampFormat: TimestampFormat; +}) { + const userImages = props.row.message.attachments ?? []; + const displayedUserMessage = deriveDisplayedUserMessageState(props.row.message.text); + const terminalContexts = displayedUserMessage.contexts; + + return ( +
+
+ {userImages.length > 0 ? ( +
+ {userImages.map((image: NonNullable[number]) => (
- {renderRowContent(row)} + {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )}
- ); - })} + ))} +
+ ) : null} + {displayedUserMessage.visibleText.trim().length > 0 || terminalContexts.length > 0 ? ( + + ) : null} +
+
+ {displayedUserMessage.copyText ? ( + + ) : null} + {props.canRevertAgentWork ? ( + + ) : null} +
+

+ {formatTimestamp(props.row.message.createdAt, props.timestampFormat)} +

- )} +
+
+ ); +}); + +const AssistantMessageRow = memo(function AssistantMessageRow(props: { + row: Extract; + completionSummary: string | null; + turnDiffSummary: TurnDiffSummary | null; + changedFilesExpanded: boolean; + activeTurnInProgress: boolean; + activeTurnId: TurnId | null | undefined; + markdownCwd: string | undefined; + resolvedTheme: "light" | "dark"; + timestampFormat: TimestampFormat; + nowIso: string | undefined; + onSetChangedFilesExpanded: (turnId: TurnId, expanded: boolean) => void; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; +}) { + const messageText = + props.row.message.text || (props.row.message.streaming ? "" : "(empty response)"); + const assistantTurnStillInProgress = + props.activeTurnInProgress && + props.activeTurnId !== null && + props.activeTurnId !== undefined && + props.row.message.turnId === props.activeTurnId; + const assistantCopyState = resolveAssistantMessageCopyState({ + text: props.row.message.text ?? null, + showCopyButton: props.row.showAssistantCopyButton, + streaming: props.row.message.streaming || assistantTurnStillInProgress, + }); + + return ( + <> + {props.row.showCompletionDivider ? ( +
+ + + {props.completionSummary ? `Response • ${props.completionSummary}` : "Response"} + + +
+ ) : null} +
+ + {props.turnDiffSummary && props.turnDiffSummary.files.length > 0 ? ( + + ) : null} +
+ {props.row.message.streaming ? ( + + ) : ( +

+ {formatMessageMeta( + props.row.message.createdAt, + formatElapsed(props.row.durationStart, props.row.message.completedAt), + props.timestampFormat, + )} +

+ )} + {assistantCopyState.visible ? ( +
+ +
+ ) : null} +
+
+ + ); +}); + +const ChangedFilesSummaryCard = memo(function ChangedFilesSummaryCard(props: { + turnDiffSummary: TurnDiffSummary; + allDirectoriesExpanded: boolean; + resolvedTheme: "light" | "dark"; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onSetChangedFilesExpanded: (turnId: TurnId, expanded: boolean) => void; +}) { + const checkpointFiles = props.turnDiffSummary.files; + const summaryStat = summarizeTurnDiffStats(checkpointFiles); - {nonVirtualizedRows.map((row) => ( -
{renderRowContent(row)}
- ))} + return ( +
+
+

+ Changed files ({String(checkpointFiles.length)}) + {hasNonZeroStat(summaryStat) ? ( + <> + + + + ) : null} +

+
+ + +
+
+
); }); -type TimelineEntry = ReturnType[number]; -type TimelineMessage = Extract["message"]; -type TimelineWorkEntry = Extract["groupedEntries"][number]; -type TimelineRow = MessagesTimelineRow; +function workEntriesEqual( + previous: ReadonlyArray, + next: ReadonlyArray, +): boolean { + return previous.length === next.length && previous.every((entry, index) => entry === next[index]); +} function formatWorkingTimer(startIso: string, endIso: string): string | null { const startedAtMs = Date.parse(startIso); @@ -670,6 +1175,67 @@ function formatMessageMeta( return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; } +function useNowIso(enabled: boolean, nowIsoOverride?: string): string | null { + const [nowTick, setNowTick] = useState(() => Date.now()); + + useEffect(() => { + if (!enabled || nowIsoOverride) { + return; + } + const timer = window.setInterval(() => { + setNowTick(Date.now()); + }, 1000); + return () => { + window.clearInterval(timer); + }; + }, [enabled, nowIsoOverride]); + + if (!enabled) { + return nowIsoOverride ?? null; + } + + return nowIsoOverride ?? new Date(nowTick).toISOString(); +} + +const LiveAssistantMessageMeta = memo(function LiveAssistantMessageMeta(props: { + createdAt: string; + durationStart: string | null; + nowIsoOverride: string | undefined; + timestampFormat: TimestampFormat; +}) { + const nowIso = useNowIso(true, props.nowIsoOverride); + const duration = + props.durationStart && nowIso ? formatElapsed(props.durationStart, nowIso) : null; + + return ( +

+ {formatMessageMeta(props.createdAt, duration, props.timestampFormat)} +

+ ); +}); + +const LiveWorkingStatus = memo(function LiveWorkingStatus(props: { + createdAt: string | null; + nowIsoOverride: string | undefined; +}) { + const nowIso = useNowIso(Boolean(props.createdAt), props.nowIsoOverride); + const label = + props.createdAt && nowIso + ? `Working for ${formatWorkingTimer(props.createdAt, nowIso) ?? "0s"}` + : "Working..."; + + return ( +
+ + + + + + {label} +
+ ); +}); + const UserMessageTerminalContextInlineLabel = memo( function UserMessageTerminalContextInlineLabel(props: { context: ParsedTerminalContextEntry }) { const tooltipText = diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx index be3cf5c67a..6ae2a7989d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx @@ -172,11 +172,12 @@ function createBaseTimelineProps(input: { isWorking: false, activeTurnInProgress: false, activeTurnStartedAt: null, - timelineEntries: deriveTimelineEntries( + historicalTimelineEntries: deriveTimelineEntries( input.messages ?? [], input.proposedPlans ?? [], input.workEntries ?? [], ), + liveTimelineEntries: [], completionDividerBeforeEntryId: input.completionDividerBeforeEntryId ?? null, completionSummary: null, turnDiffSummaryByAssistantMessageId: input.turnDiffSummaryByAssistantMessageId ?? new Map(), @@ -556,7 +557,7 @@ async function measureTimelineRow(input: { ); const rows = deriveMessagesTimelineRows({ - timelineEntries: input.props.timelineEntries, + timelineEntries: [...input.props.historicalTimelineEntries, ...input.props.liveTimelineEntries], completionDividerBeforeEntryId: input.props.completionDividerBeforeEntryId, isWorking: input.props.isWorking, activeTurnStartedAt: input.props.activeTurnStartedAt, diff --git a/apps/web/src/components/chat/MessagesTimelineContainer.test.ts b/apps/web/src/components/chat/MessagesTimelineContainer.test.ts new file mode 100644 index 0000000000..62233c3cbc --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimelineContainer.test.ts @@ -0,0 +1,51 @@ +import { MessageId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { excludeOptimisticMessagesAlreadyRendered } from "./MessagesTimelineContainer"; +import type { ChatMessage } from "../../types"; + +function createUserMessage(id: string): ChatMessage { + return { + id: MessageId.make(id), + role: "user", + text: id, + createdAt: "2026-04-12T12:00:00.000Z", + streaming: false, + }; +} + +function createAssistantMessage(id: string): ChatMessage { + return { + id: MessageId.make(id), + role: "assistant", + text: id, + createdAt: "2026-04-12T12:00:01.000Z", + streaming: false, + }; +} + +describe("excludeOptimisticMessagesAlreadyRendered", () => { + it("drops optimistic user messages once the same id is already rendered by the server", () => { + const optimisticUserMessages = [ + createUserMessage("optimistic-user"), + createUserMessage("still-pending-user"), + ]; + const renderedServerMessages = [ + createUserMessage("optimistic-user"), + createAssistantMessage("settled-assistant"), + ]; + + expect( + excludeOptimisticMessagesAlreadyRendered(optimisticUserMessages, renderedServerMessages), + ).toEqual([optimisticUserMessages[1]]); + }); + + it("keeps optimistic user messages that are not yet present in server-rendered history", () => { + const optimisticUserMessages = [createUserMessage("still-pending-user")]; + const renderedServerMessages = [createAssistantMessage("settled-assistant")]; + + expect( + excludeOptimisticMessagesAlreadyRendered(optimisticUserMessages, renderedServerMessages), + ).toEqual(optimisticUserMessages); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimelineContainer.tsx b/apps/web/src/components/chat/MessagesTimelineContainer.tsx new file mode 100644 index 0000000000..7a400a3f4c --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimelineContainer.tsx @@ -0,0 +1,596 @@ +import { + type EnvironmentId, + type MessageId, + type ScopedThreadRef, + type TurnId, + type ThreadId, +} from "@t3tools/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + deriveActiveWorkStartedAt, + deriveCompletionDividerBeforeEntryId, + deriveTimelineEntries, + deriveWorkLogEntries, + formatElapsed, + hasToolActivityForTurn, + inferCheckpointTurnCountByTurnId, +} from "../../session-logic"; +import { + type SessionPhase, + type ChatMessage, + type Thread, + type TurnDiffSummary, +} from "../../types"; +import { type TimestampFormat } from "@t3tools/contracts/settings"; +import { + HistoricalMessagesTimelineSection, + LiveMessagesTimelineSection, + TimelineEmptyState, +} from "./MessagesTimeline"; +import { type ExpandedImagePreview } from "./ExpandedImagePreview"; +import { createThreadTimelineSliceSelectorByRef } from "../../storeSelectors"; +import { useStore } from "../../store"; +import { useUiStateStore } from "../../uiStateStore"; + +const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID: Record = {}; +const EMPTY_CHAT_MESSAGES: ChatMessage[] = []; +const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; +const EMPTY_REVERT_TURN_COUNT_BY_USER_MESSAGE_ID = new Map(); +const NOOP_REVERT_USER_MESSAGE = (_messageId: MessageId) => {}; + +export function excludeOptimisticMessagesAlreadyRendered( + optimisticUserMessages: readonly ChatMessage[], + renderedServerMessages: readonly ChatMessage[], +): ChatMessage[] { + if (optimisticUserMessages.length === 0 || renderedServerMessages.length === 0) { + return [...optimisticUserMessages]; + } + + const renderedServerIds = new Set(renderedServerMessages.map((message) => message.id)); + return optimisticUserMessages.filter((message) => !renderedServerIds.has(message.id)); +} + +interface MessagesTimelineContainerProps { + activeLatestTurn: Thread["latestTurn"] | null; + activeTurnId: TurnId | null; + activeTurnInProgress: boolean; + activeThreadEnvironmentId: EnvironmentId; + activeThreadId: ThreadId; + activeThreadSession: Parameters[1]; + draftActivities: Thread["activities"]; + isRevertingCheckpoint: boolean; + isWorking: boolean; + latestTurnSettled: boolean; + localDispatchStartedAt: string | null; + markdownCwd: string | undefined; + onImageExpand: (preview: ExpandedImagePreview) => void; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onRevertToTurnCount: (turnCount: number) => void; + phase: SessionPhase; + resolvedTheme: "light" | "dark"; + scheduleStickToBottom: () => void; + scrollContainer: HTMLDivElement | null; + shouldAutoScrollRef: React.MutableRefObject; + timestampFormat: TimestampFormat; + threadRef: ScopedThreadRef | null; + draftMessages: ChatMessage[]; + draftProposedPlans: Thread["proposedPlans"]; + draftTurnDiffSummaries: Thread["turnDiffSummaries"]; + optimisticUserMessages: ChatMessage[]; + attachmentPreviewHandoffByMessageId: Record; + clearAttachmentPreviewHandoff: ( + messageId: MessageId, + previewUrls?: ReadonlyArray, + ) => void; + workspaceRoot: string | undefined; +} + +export const MessagesTimelineContainer = memo(function MessagesTimelineContainer( + props: MessagesTimelineContainerProps, +) { + const { + activeLatestTurn, + activeTurnId, + activeTurnInProgress, + activeThreadEnvironmentId, + activeThreadId, + activeThreadSession, + draftActivities, + isRevertingCheckpoint, + isWorking, + latestTurnSettled, + localDispatchStartedAt, + markdownCwd, + onImageExpand, + onOpenTurnDiff, + onRevertToTurnCount, + phase, + resolvedTheme, + scheduleStickToBottom, + scrollContainer, + shouldAutoScrollRef, + timestampFormat, + threadRef, + draftMessages, + draftProposedPlans, + draftTurnDiffSummaries, + optimisticUserMessages, + attachmentPreviewHandoffByMessageId, + clearAttachmentPreviewHandoff, + workspaceRoot, + } = props; + const threadEnvironmentId = threadRef?.environmentId; + const threadId = threadRef?.threadId; + const stableThreadRef = useMemo( + () => (threadEnvironmentId && threadId ? scopeThreadRef(threadEnvironmentId, threadId) : null), + [threadEnvironmentId, threadId], + ); + const threadKey = useMemo( + () => (stableThreadRef ? scopedThreadKey(stableThreadRef) : null), + [stableThreadRef], + ); + const serverTimelineSelector = useMemo( + () => createThreadTimelineSliceSelectorByRef(stableThreadRef), + [stableThreadRef], + ); + const serverTimelineSlices = useStore(serverTimelineSelector); + const changedFilesExpandedByTurnId = useUiStateStore((store) => + threadKey + ? (store.threadChangedFilesExpandedById[threadKey] ?? EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID) + : EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID, + ); + const setThreadChangedFilesExpanded = useUiStateStore( + (store) => store.setThreadChangedFilesExpanded, + ); + const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); + const applyPreviewHandoff = useCallback( + (messages: ChatMessage[]) => { + let messagesWithPreviewHandoff = messages; + if (Object.keys(attachmentPreviewHandoffByMessageId).length > 0) { + let nextMessages: ChatMessage[] | null = null; + + for (const [messageIndex, message] of messages.entries()) { + if (message.role !== "user" || !message.attachments || message.attachments.length === 0) { + if (nextMessages) { + nextMessages.push(message); + } + continue; + } + + const handoffPreviewUrls = attachmentPreviewHandoffByMessageId[message.id]; + if (!handoffPreviewUrls || handoffPreviewUrls.length === 0) { + if (nextMessages) { + nextMessages.push(message); + } + continue; + } + + let changed = false; + let imageIndex = 0; + const attachments = [...message.attachments]; + + for ( + let attachmentIndex = 0; + attachmentIndex < attachments.length; + attachmentIndex += 1 + ) { + const attachment = attachments[attachmentIndex]; + if (!attachment || attachment.type !== "image") { + continue; + } + + const handoffPreviewUrl = handoffPreviewUrls[imageIndex]; + imageIndex += 1; + if (!handoffPreviewUrl || attachment.previewUrl === handoffPreviewUrl) { + continue; + } + + changed = true; + attachments[attachmentIndex] = { + ...attachment, + previewUrl: handoffPreviewUrl, + }; + } + + if (!changed) { + if (nextMessages) { + nextMessages.push(message); + } + continue; + } + + if (!nextMessages) { + nextMessages = messages.slice(0, messageIndex); + } + + nextMessages.push({ + ...message, + attachments, + }); + } + + messagesWithPreviewHandoff = nextMessages ?? messages; + } + + return messagesWithPreviewHandoff; + }, + [attachmentPreviewHandoffByMessageId], + ); + const historicalTimelineMessages = useMemo(() => { + const baseMessages = threadRef ? serverTimelineSlices.historicalMessages : draftMessages; + return applyPreviewHandoff(baseMessages); + }, [applyPreviewHandoff, draftMessages, serverTimelineSlices.historicalMessages, threadRef]); + const activeWorkEntries = threadRef + ? serverTimelineSlices.activeWorkEntries + : deriveWorkLogEntries(draftActivities, activeLatestTurn?.turnId ?? undefined); + const settledLiveMessages = useMemo(() => { + const baseMessages = threadRef ? serverTimelineSlices.liveMessages : EMPTY_CHAT_MESSAGES; + const messagesWithPreviewHandoff = applyPreviewHandoff(baseMessages); + const canPromoteSettledLiveMessages = + optimisticUserMessages.length > 0 && + messagesWithPreviewHandoff.length > 0 && + messagesWithPreviewHandoff.every( + (message) => message.role === "assistant" && !message.streaming, + ); + return canPromoteSettledLiveMessages ? messagesWithPreviewHandoff : EMPTY_CHAT_MESSAGES; + }, [ + applyPreviewHandoff, + optimisticUserMessages.length, + serverTimelineSlices.liveMessages, + threadRef, + ]); + const liveTimelineMessages = useMemo(() => { + const baseMessages = threadRef ? serverTimelineSlices.liveMessages : EMPTY_CHAT_MESSAGES; + const messagesWithPreviewHandoff = applyPreviewHandoff(baseMessages); + if (settledLiveMessages.length > 0) { + const pendingMessages = excludeOptimisticMessagesAlreadyRendered(optimisticUserMessages, [ + ...historicalTimelineMessages, + ...settledLiveMessages, + ]); + return pendingMessages; + } + if (optimisticUserMessages.length === 0) { + return messagesWithPreviewHandoff; + } + const pendingMessages = excludeOptimisticMessagesAlreadyRendered(optimisticUserMessages, [ + ...historicalTimelineMessages, + ...messagesWithPreviewHandoff, + ]); + if (pendingMessages.length === 0) { + return messagesWithPreviewHandoff; + } + return [...pendingMessages, ...messagesWithPreviewHandoff]; + }, [ + applyPreviewHandoff, + historicalTimelineMessages, + optimisticUserMessages, + settledLiveMessages, + serverTimelineSlices.liveMessages, + threadRef, + ]); + const promotedLiveProposedPlans = + settledLiveMessages.length > 0 + ? threadRef + ? serverTimelineSlices.liveProposedPlans + : EMPTY_PROPOSED_PLANS + : EMPTY_PROPOSED_PLANS; + const historicalProposedPlans = useMemo(() => { + const baseProposedPlans = threadRef + ? serverTimelineSlices.historicalProposedPlans + : draftProposedPlans; + return promotedLiveProposedPlans.length > 0 + ? [...baseProposedPlans, ...promotedLiveProposedPlans] + : baseProposedPlans; + }, [ + draftProposedPlans, + promotedLiveProposedPlans, + serverTimelineSlices.historicalProposedPlans, + threadRef, + ]); + const liveProposedPlans = + settledLiveMessages.length > 0 + ? EMPTY_PROPOSED_PLANS + : threadRef + ? serverTimelineSlices.liveProposedPlans + : EMPTY_PROPOSED_PLANS; + const effectiveHistoricalTimelineMessages = useMemo( + () => + settledLiveMessages.length > 0 + ? [...historicalTimelineMessages, ...settledLiveMessages] + : historicalTimelineMessages, + [historicalTimelineMessages, settledLiveMessages], + ); + const turnDiffSummaries = threadRef + ? serverTimelineSlices.turnDiffSummaries + : draftTurnDiffSummaries; + const latestTurnHasToolActivity = threadRef + ? serverTimelineSlices.latestTurnHasToolActivity + : hasToolActivityForTurn(draftActivities, activeLatestTurn?.turnId); + const historicalTimelineEntries = useMemo( + () => deriveTimelineEntries(effectiveHistoricalTimelineMessages, historicalProposedPlans, []), + [effectiveHistoricalTimelineMessages, historicalProposedPlans], + ); + const liveTimelineEntries = useMemo( + () => deriveTimelineEntries(liveTimelineMessages, liveProposedPlans, activeWorkEntries), + [activeWorkEntries, liveProposedPlans, liveTimelineMessages], + ); + const timelineEntries = useMemo( + () => [...historicalTimelineEntries, ...liveTimelineEntries], + [historicalTimelineEntries, liveTimelineEntries], + ); + const activeWorkStartedAt = useMemo( + () => deriveActiveWorkStartedAt(activeLatestTurn, activeThreadSession, localDispatchStartedAt), + [activeLatestTurn, activeThreadSession, localDispatchStartedAt], + ); + const turnDiffSummaryByAssistantMessageId = useMemo(() => { + const byMessageId = new Map(); + for (const summary of turnDiffSummaries) { + if (!summary.assistantMessageId) { + continue; + } + byMessageId.set(summary.assistantMessageId, summary); + } + return byMessageId; + }, [turnDiffSummaries]); + const inferredCheckpointTurnCountByTurnId = useMemo( + () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), + [turnDiffSummaries], + ); + const historicalRevertTurnCountByUserMessageId = useMemo( + () => + deriveRevertTurnCountByUserMessageId({ + inferredCheckpointTurnCountByTurnId, + timelineEntries: historicalTimelineEntries, + turnDiffSummaryByAssistantMessageId, + }), + [ + historicalTimelineEntries, + inferredCheckpointTurnCountByTurnId, + turnDiffSummaryByAssistantMessageId, + ], + ); + const completionSummary = useMemo(() => { + if (!latestTurnSettled) return null; + if (!activeLatestTurn?.startedAt) return null; + if (!activeLatestTurn.completedAt) return null; + if (!latestTurnHasToolActivity) return null; + + const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); + return elapsed ? `Worked for ${elapsed}` : null; + }, [ + activeLatestTurn?.completedAt, + activeLatestTurn?.startedAt, + latestTurnHasToolActivity, + latestTurnSettled, + ]); + const completionDividerBeforeEntryId = useMemo(() => { + if (!latestTurnSettled) return null; + if (!completionSummary) return null; + return deriveCompletionDividerBeforeEntryId(timelineEntries, activeLatestTurn); + }, [activeLatestTurn, completionSummary, latestTurnSettled, timelineEntries]); + const messageCount = historicalTimelineMessages.length + liveTimelineMessages.length; + const onToggleWorkGroup = useCallback((groupId: string) => { + setExpandedWorkGroups((existing) => ({ + ...existing, + [groupId]: !existing[groupId], + })); + }, []); + const onRevertUserMessage = useCallback( + (messageId: MessageId) => { + const targetTurnCount = historicalRevertTurnCountByUserMessageId.get(messageId); + if (typeof targetTurnCount !== "number") { + return; + } + onRevertToTurnCount(targetTurnCount); + }, + [historicalRevertTurnCountByUserMessageId, onRevertToTurnCount], + ); + const onSetChangedFilesExpanded = useCallback( + (turnId: TurnId, expanded: boolean) => { + if (!threadKey) { + return; + } + setThreadChangedFilesExpanded(threadKey, turnId, expanded); + }, + [setThreadChangedFilesExpanded, threadKey], + ); + const activeThreadIdRef = useRef(activeThreadId); + + useEffect(() => { + const serverMessages = [ + ...serverTimelineSlices.historicalMessages, + ...serverTimelineSlices.liveMessages, + ]; + if (!threadKey || typeof Image === "undefined" || serverMessages.length === 0) { + return; + } + + const cleanups: Array<() => void> = []; + + for (const [messageId, handoffPreviewUrls] of Object.entries( + attachmentPreviewHandoffByMessageId, + )) { + const serverMessage = serverMessages.find( + (message) => message.id === messageId && message.role === "user", + ); + if (!serverMessage?.attachments || serverMessage.attachments.length === 0) { + continue; + } + + const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => + attachment.type === "image" && attachment.previewUrl ? [attachment.previewUrl] : [], + ); + if ( + serverPreviewUrls.length === 0 || + serverPreviewUrls.length !== handoffPreviewUrls.length || + serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:")) + ) { + continue; + } + + let cancelled = false; + const imageInstances: HTMLImageElement[] = []; + + const preloadServerPreviews = Promise.all( + serverPreviewUrls.map( + (previewUrl) => + new Promise((resolve, reject) => { + const image = new Image(); + imageInstances.push(image); + const handleLoad = () => resolve(); + const handleError = () => + reject(new Error(`Failed to load server preview for ${messageId}.`)); + image.addEventListener("load", handleLoad, { once: true }); + image.addEventListener("error", handleError, { once: true }); + image.src = previewUrl; + }), + ), + ); + + void preloadServerPreviews + .then(() => { + if (cancelled) { + return; + } + clearAttachmentPreviewHandoff(messageId as MessageId, handoffPreviewUrls); + }) + .catch(() => undefined); + + cleanups.push(() => { + cancelled = true; + for (const image of imageInstances) { + image.src = ""; + } + }); + } + + return () => { + for (const cleanup of cleanups) { + cleanup(); + } + }; + }, [ + attachmentPreviewHandoffByMessageId, + clearAttachmentPreviewHandoff, + serverTimelineSlices.historicalMessages, + serverTimelineSlices.liveMessages, + threadKey, + ]); + + useEffect(() => { + if (activeThreadIdRef.current === activeThreadId) { + return; + } + activeThreadIdRef.current = activeThreadId; + setExpandedWorkGroups({}); + }, [activeThreadId]); + + useEffect(() => { + if (!shouldAutoScrollRef.current) return; + scheduleStickToBottom(); + }, [messageCount, scheduleStickToBottom, shouldAutoScrollRef]); + + useEffect(() => { + if (phase !== "running") return; + if (!shouldAutoScrollRef.current) return; + scheduleStickToBottom(); + }, [liveTimelineEntries, phase, scheduleStickToBottom, shouldAutoScrollRef]); + + if (timelineEntries.length === 0 && !isWorking) { + return ; + } + + return ( +
+ + +
+ ); +}); + +function deriveRevertTurnCountByUserMessageId({ + inferredCheckpointTurnCountByTurnId, + timelineEntries, + turnDiffSummaryByAssistantMessageId, +}: { + inferredCheckpointTurnCountByTurnId: Record; + timelineEntries: ReturnType; + turnDiffSummaryByAssistantMessageId: Map; +}): Map { + if (timelineEntries.length === 0) { + return EMPTY_REVERT_TURN_COUNT_BY_USER_MESSAGE_ID; + } + + const byUserMessageId = new Map(); + for (let index = 0; index < timelineEntries.length; index += 1) { + const entry = timelineEntries[index]; + if (!entry || entry.kind !== "message" || entry.message.role !== "user") { + continue; + } + + for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) { + const nextEntry = timelineEntries[nextIndex]; + if (!nextEntry || nextEntry.kind !== "message") { + continue; + } + if (nextEntry.message.role === "user") { + break; + } + const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); + if (!summary) { + continue; + } + const turnCount = + summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; + if (typeof turnCount !== "number") { + break; + } + byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); + break; + } + } + + return byUserMessageId.size === 0 ? EMPTY_REVERT_TURN_COUNT_BY_USER_MESSAGE_ID : byUserMessageId; +} diff --git a/apps/web/src/components/sidebar/sidebarConstants.ts b/apps/web/src/components/sidebar/sidebarConstants.ts new file mode 100644 index 0000000000..69b80d55f3 --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarConstants.ts @@ -0,0 +1 @@ +export const THREAD_PREVIEW_LIMIT = 6; diff --git a/apps/web/src/components/sidebar/sidebarControllers.tsx b/apps/web/src/components/sidebar/sidebarControllers.tsx new file mode 100644 index 0000000000..ed4246df66 --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarControllers.tsx @@ -0,0 +1,417 @@ +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; +import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; +import { useParams } from "@tanstack/react-router"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { useShallow } from "zustand/react/shallow"; +import { isTerminalFocused } from "../../lib/terminalFocus"; +import { resolveThreadRouteRef } from "../../threadRoutes"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + shouldShowThreadJumpHints, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, +} from "../../keybindings"; +import { selectThreadTerminalState, useTerminalStateStore } from "../../terminalStateStore"; +import { useUiStateStore } from "../../uiStateStore"; +import { + resolveAdjacentThreadId, + shouldClearThreadSelectionOnMouseDown, + useThreadJumpHintVisibility, +} from "../Sidebar.logic"; +import { + createSidebarActiveRouteProjectKeySelectorByRef, + createSidebarSortedThreadKeysByLogicalProjectSelector, +} from "./sidebarSelectors"; +import type { LogicalProjectKey } from "../../logicalProject"; +import { + setSidebarKeyboardState, + useSidebarExpandedThreadListsByProject, +} from "./sidebarViewStore"; +import { useServerKeybindings } from "../../rpc/serverState"; +import { useStore } from "../../store"; +import { useThreadSelectionStore } from "../../threadSelectionStore"; +import { THREAD_PREVIEW_LIMIT } from "./sidebarConstants"; + +const EMPTY_THREAD_JUMP_LABELS = new Map(); + +function readSidebarShortcutContext(routeThreadRef: ScopedThreadRef | null) { + return { + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + }; +} + +function buildThreadJumpLabelMap(input: { + keybindings: ReturnType; + platform: string; + terminalOpen: boolean; + threadJumpCommandByKey: ReadonlyMap< + string, + NonNullable> + >; +}): ReadonlyMap { + if (input.threadJumpCommandByKey.size === 0) { + return EMPTY_THREAD_JUMP_LABELS; + } + + const shortcutLabelOptions = { + platform: input.platform, + context: { + terminalFocus: false, + terminalOpen: input.terminalOpen, + }, + } as const; + const mapping = new Map(); + for (const [threadKey, command] of input.threadJumpCommandByKey) { + const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); + if (label) { + mapping.set(threadKey, label); + } + } + return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; +} + +function useSidebarKeyboardController(input: { + physicalToLogicalKey: ReadonlyMap; + sortedProjectKeys: readonly LogicalProjectKey[]; + expandedThreadListsByProject: ReadonlySet; + routeThreadRef: ScopedThreadRef | null; + routeThreadKey: string | null; + platform: string; + keybindings: ReturnType; + navigateToThread: (threadRef: ScopedThreadRef) => void; + threadSortOrder: SidebarThreadSortOrder; +}) { + const { + physicalToLogicalKey, + sortedProjectKeys, + expandedThreadListsByProject, + routeThreadRef, + routeThreadKey, + platform, + keybindings, + navigateToThread, + threadSortOrder, + } = input; + const projectExpandedStates = useUiStateStore( + useShallow((store) => + sortedProjectKeys.map((projectKey) => store.projectExpandedById[projectKey] ?? true), + ), + ); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const sortedThreadKeysByLogicalProject = useStore( + useMemo( + () => + createSidebarSortedThreadKeysByLogicalProjectSelector({ + physicalToLogicalKey, + threadSortOrder, + }), + [physicalToLogicalKey, threadSortOrder], + ), + ); + const visibleSidebarThreadKeys = useMemo( + () => + sortedProjectKeys.flatMap((projectKey, index) => { + const projectThreadKeys = sortedThreadKeysByLogicalProject.get(projectKey) ?? []; + const projectExpanded = projectExpandedStates[index] ?? true; + const activeThreadKey = routeThreadKey ?? undefined; + const pinnedCollapsedThread = + !projectExpanded && activeThreadKey + ? (projectThreadKeys.find((threadKey) => threadKey === activeThreadKey) ?? null) + : null; + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } + + const isThreadListExpanded = expandedThreadListsByProject.has(projectKey); + const hasOverflowingThreads = projectThreadKeys.length > THREAD_PREVIEW_LIMIT; + const previewThreadKeys = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreadKeys + : projectThreadKeys.slice(0, THREAD_PREVIEW_LIMIT); + return pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreadKeys; + }), + [ + expandedThreadListsByProject, + projectExpandedStates, + routeThreadKey, + sortedProjectKeys, + sortedThreadKeysByLogicalProject, + ], + ); + const threadJumpCommandByKey = useMemo(() => { + const mapping = new Map>>(); + for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(threadKey, jumpCommand); + } + + return mapping; + }, [visibleSidebarThreadKeys]); + const threadJumpThreadKeys = useMemo( + () => [...threadJumpCommandByKey.keys()], + [threadJumpCommandByKey], + ); + const getCurrentSidebarShortcutContext = useCallback( + () => readSidebarShortcutContext(routeThreadRef), + [routeThreadRef], + ); + const threadJumpLabelByKey = useMemo( + () => + showThreadJumpHints + ? buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: getCurrentSidebarShortcutContext().terminalOpen, + threadJumpCommandByKey, + }) + : EMPTY_THREAD_JUMP_LABELS, + [ + getCurrentSidebarShortcutContext, + keybindings, + platform, + showThreadJumpHints, + threadJumpCommandByKey, + ], + ); + const threadJumpLabelsRef = useRef>(threadJumpLabelByKey); + threadJumpLabelsRef.current = threadJumpLabelByKey; + const showThreadJumpHintsRef = useRef(showThreadJumpHints); + showThreadJumpHintsRef.current = showThreadJumpHints; + const latestKeyboardStateRef = useRef({ + keybindings, + navigateToThread, + platform, + routeThreadKey, + routeThreadRef, + threadJumpThreadKeys, + visibleSidebarThreadKeys, + }); + latestKeyboardStateRef.current = { + keybindings, + navigateToThread, + platform, + routeThreadKey, + routeThreadRef, + threadJumpThreadKeys, + visibleSidebarThreadKeys, + }; + const updateThreadJumpHintsVisibilityRef = useRef(updateThreadJumpHintsVisibility); + updateThreadJumpHintsVisibilityRef.current = updateThreadJumpHintsVisibility; + + useEffect(() => { + const clearThreadJumpHints = () => { + updateThreadJumpHintsVisibilityRef.current(false); + }; + const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + event.key !== "Meta" && + event.key !== "Control" && + event.key !== "Alt" && + event.key !== "Shift" && + !showThreadJumpHintsRef.current && + threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; + const getCurrentSidebarShortcutContext = () => + readSidebarShortcutContext(latestKeyboardStateRef.current.routeThreadRef); + + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } + + const { + keybindings, + navigateToThread, + platform, + routeThreadKey, + threadJumpThreadKeys, + visibleSidebarThreadKeys, + } = latestKeyboardStateRef.current; + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + if ( + showThreadJumpHintsRef.current || + threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS + ) { + clearThreadJumpHints(); + } + } else { + updateThreadJumpHintsVisibilityRef.current(true); + } + + if (event.defaultPrevented || event.repeat) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + platform, + context: shortcutContext, + }); + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: visibleSidebarThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }); + if (!targetThreadKey) { + return; + } + const targetThread = parseScopedThreadKey(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(targetThread); + return; + } + + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; + if (!targetThreadKey) { + return; + } + const targetThread = parseScopedThreadKey(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(targetThread); + }; + + const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { + if (shouldIgnoreThreadJumpHintUpdate(event)) { + return; + } + + const { keybindings, platform } = latestKeyboardStateRef.current; + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + if (!shouldShowHints) { + clearThreadJumpHints(); + return; + } + updateThreadJumpHintsVisibilityRef.current(true); + }; + + const onWindowBlur = () => { + clearThreadJumpHints(); + }; + + window.addEventListener("keydown", onWindowKeyDown); + window.addEventListener("keyup", onWindowKeyUp); + window.addEventListener("blur", onWindowBlur); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + window.removeEventListener("keyup", onWindowKeyUp); + window.removeEventListener("blur", onWindowBlur); + }; + }, []); + + return threadJumpLabelByKey; +} + +export const SidebarSelectionController = memo(function SidebarSelectionController() { + const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size); + const clearSelection = useThreadSelectionStore((state) => state.clearSelection); + const selectedThreadCountRef = useRef(selectedThreadCount); + selectedThreadCountRef.current = selectedThreadCount; + const clearSelectionRef = useRef(clearSelection); + clearSelectionRef.current = clearSelection; + + useEffect(() => { + const onMouseDown = (event: globalThis.MouseEvent) => { + if (selectedThreadCountRef.current === 0) { + return; + } + const target = event.target instanceof HTMLElement ? event.target : null; + if (!shouldClearThreadSelectionOnMouseDown(target)) { + return; + } + clearSelectionRef.current(); + }; + + window.addEventListener("mousedown", onMouseDown); + return () => { + window.removeEventListener("mousedown", onMouseDown); + }; + }, []); + + return null; +}); + +export const SidebarKeyboardController = memo(function SidebarKeyboardController(props: { + navigateToThread: (threadRef: ScopedThreadRef) => void; + physicalToLogicalKey: ReadonlyMap; + sortedProjectKeys: readonly LogicalProjectKey[]; + sidebarThreadSortOrder: SidebarThreadSortOrder; +}) { + const { navigateToThread, physicalToLogicalKey, sortedProjectKeys, sidebarThreadSortOrder } = + props; + const expandedThreadListsByProject = useSidebarExpandedThreadListsByProject(); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const activeRouteProjectKey = useStore( + useMemo( + () => createSidebarActiveRouteProjectKeySelectorByRef(routeThreadRef, physicalToLogicalKey), + [physicalToLogicalKey, routeThreadRef], + ), + ); + const keybindings = useServerKeybindings(); + const platform = navigator.platform; + const threadJumpLabelByKey = useSidebarKeyboardController({ + physicalToLogicalKey, + sortedProjectKeys, + expandedThreadListsByProject, + routeThreadRef, + routeThreadKey, + platform, + keybindings, + navigateToThread, + threadSortOrder: sidebarThreadSortOrder, + }); + + useEffect(() => { + setSidebarKeyboardState({ + activeRouteProjectKey, + activeRouteThreadKey: routeThreadKey, + threadJumpLabelByKey, + }); + }, [activeRouteProjectKey, routeThreadKey, threadJumpLabelByKey]); + + return null; +}); diff --git a/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts b/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts new file mode 100644 index 0000000000..4e2f57eae5 --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarProjectSnapshots.ts @@ -0,0 +1,214 @@ +import type { ModelSelection, ScopedProjectRef } from "@t3tools/contracts"; +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { deriveLogicalProjectKey, type LogicalProjectKey } from "../../logicalProject"; +import type { Project, ProjectScript } from "../../types"; + +export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; + +export type SidebarProjectSnapshot = Project & { + projectKey: LogicalProjectKey; + environmentPresence: EnvironmentPresence; + memberProjectRefs: readonly ScopedProjectRef[]; + remoteEnvironmentLabels: readonly string[]; +}; + +type SavedEnvironmentRegistryEntry = { + label?: string | null; +} | null; + +type SavedEnvironmentRuntimeEntry = { + descriptor?: { + label?: string | null; + } | null; +} | null; + +function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function refsEqual(left: readonly ScopedProjectRef[], right: readonly ScopedProjectRef[]): boolean { + return ( + left.length === right.length && + left.every( + (ref, index) => + ref.environmentId === right[index]?.environmentId && + ref.projectId === right[index]?.projectId, + ) + ); +} + +function modelSelectionsEqual(left: ModelSelection | null, right: ModelSelection | null): boolean { + if (left === right) { + return true; + } + if (!left || !right) { + return false; + } + + return ( + left.provider === right.provider && + left.model === right.model && + JSON.stringify(left.options ?? null) === JSON.stringify(right.options ?? null) + ); +} + +function projectScriptsEqual( + left: readonly ProjectScript[], + right: readonly ProjectScript[], +): boolean { + return ( + left.length === right.length && + left.every((script, index) => { + const other = right[index]; + return ( + other !== undefined && + script.id === other.id && + script.name === other.name && + script.command === other.command && + script.icon === other.icon && + script.runOnWorktreeCreate === other.runOnWorktreeCreate + ); + }) + ); +} + +export function sidebarProjectSnapshotsEqual( + left: SidebarProjectSnapshot | undefined, + right: SidebarProjectSnapshot, +): boolean { + return ( + left !== undefined && + left.id === right.id && + left.environmentId === right.environmentId && + left.name === right.name && + left.cwd === right.cwd && + left.repositoryIdentity === right.repositoryIdentity && + modelSelectionsEqual(left.defaultModelSelection, right.defaultModelSelection) && + left.createdAt === right.createdAt && + left.updatedAt === right.updatedAt && + projectScriptsEqual(left.scripts, right.scripts) && + left.projectKey === right.projectKey && + left.environmentPresence === right.environmentPresence && + refsEqual(left.memberProjectRefs, right.memberProjectRefs) && + stringArraysEqual(left.remoteEnvironmentLabels, right.remoteEnvironmentLabels) + ); +} + +export function buildSidebarPhysicalToLogicalKeyMap( + orderedProjects: readonly Project[], +): ReadonlyMap { + const mapping = new Map(); + for (const project of orderedProjects) { + const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); + mapping.set(physicalKey, deriveLogicalProjectKey(project)); + } + return mapping; +} + +export function buildSidebarProjectSnapshots(input: { + orderedProjects: readonly Project[]; + previousProjectSnapshotByKey: ReadonlyMap; + primaryEnvironmentId: EnvironmentId | null; + savedEnvironmentRegistryById: Readonly>; + savedEnvironmentRuntimeById: Readonly>; +}): { + projectSnapshotByKey: ReadonlyMap; + sidebarProjects: readonly SidebarProjectSnapshot[]; +} { + const { + orderedProjects, + previousProjectSnapshotByKey, + primaryEnvironmentId, + savedEnvironmentRegistryById, + savedEnvironmentRuntimeById, + } = input; + + const groupedMembers = new Map(); + for (const project of orderedProjects) { + const logicalKey = deriveLogicalProjectKey(project); + const existingMembers = groupedMembers.get(logicalKey); + if (existingMembers) { + existingMembers.push(project); + continue; + } + groupedMembers.set(logicalKey, [project]); + } + + const nextProjectSnapshotByKey = new Map(); + const sidebarProjects: SidebarProjectSnapshot[] = []; + const emittedProjectKeys = new Set(); + + for (const project of orderedProjects) { + const logicalKey = deriveLogicalProjectKey(project); + if (emittedProjectKeys.has(logicalKey)) { + continue; + } + emittedProjectKeys.add(logicalKey); + + const members = groupedMembers.get(logicalKey); + if (!members || members.length === 0) { + continue; + } + + const representative = + (primaryEnvironmentId + ? members.find((member) => member.environmentId === primaryEnvironmentId) + : undefined) ?? members[0]; + if (!representative) { + continue; + } + + const hasLocal = + primaryEnvironmentId !== null && + members.some((member) => member.environmentId === primaryEnvironmentId); + const hasRemote = + primaryEnvironmentId !== null + ? members.some((member) => member.environmentId !== primaryEnvironmentId) + : false; + + const nextSnapshot: SidebarProjectSnapshot = { + id: representative.id, + environmentId: representative.environmentId, + name: representative.name, + cwd: representative.cwd, + repositoryIdentity: representative.repositoryIdentity ?? null, + defaultModelSelection: representative.defaultModelSelection, + createdAt: representative.createdAt, + updatedAt: representative.updatedAt, + scripts: representative.scripts, + projectKey: logicalKey, + environmentPresence: + hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", + memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)), + remoteEnvironmentLabels: members + .filter( + (member) => + primaryEnvironmentId !== null && member.environmentId !== primaryEnvironmentId, + ) + .map((member) => { + const runtimeEnvironment = savedEnvironmentRuntimeById[member.environmentId]; + const savedEnvironment = savedEnvironmentRegistryById[member.environmentId]; + return ( + runtimeEnvironment?.descriptor?.label ?? savedEnvironment?.label ?? member.environmentId + ); + }), + }; + + const cachedSnapshot = previousProjectSnapshotByKey.get(logicalKey); + const snapshot = + cachedSnapshot && sidebarProjectSnapshotsEqual(cachedSnapshot, nextSnapshot) + ? cachedSnapshot + : nextSnapshot; + nextProjectSnapshotByKey.set(logicalKey, snapshot); + sidebarProjects.push(snapshot); + } + + return { + projectSnapshotByKey: + nextProjectSnapshotByKey.size === 0 + ? new Map() + : nextProjectSnapshotByKey, + sidebarProjects, + }; +} diff --git a/apps/web/src/components/sidebar/sidebarSelectors.ts b/apps/web/src/components/sidebar/sidebarSelectors.ts new file mode 100644 index 0000000000..ca4e3d5b61 --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarSelectors.ts @@ -0,0 +1,620 @@ +import type { EnvironmentId, ProjectId, ScopedProjectRef, ThreadId } from "@t3tools/contracts"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; +import { + sortProjectsForSidebar, + sortThreadsForSidebar, + type ThreadStatusLatestTurnSnapshot, + type ThreadStatusSessionSnapshot, +} from "../Sidebar.logic"; +import type { AppState, EnvironmentState } from "../../store"; +import type { SidebarThreadSummary } from "../../types"; +import type { LogicalProjectKey } from "../../logicalProject"; + +export interface SidebarProjectOrderingThreadSnapshot { + id: ThreadId; + environmentId: EnvironmentId; + projectId: LogicalProjectKey; + createdAt: string; + archivedAt: string | null; + updatedAt?: string | undefined; + latestUserMessageAt: string | null; +} + +const EMPTY_PROJECT_ORDERING_THREAD_SNAPSHOTS: SidebarProjectOrderingThreadSnapshot[] = []; +const EMPTY_PROJECT_THREAD_KEYS: string[] = []; +const EMPTY_PROJECT_THREAD_STATUS_INPUTS: ProjectThreadStatusInput[] = []; +const EMPTY_SORTED_THREAD_KEYS_BY_LOGICAL_PROJECT = new Map(); +const EMPTY_SORTED_PROJECT_KEYS: LogicalProjectKey[] = []; + +function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +export interface ProjectThreadStatusInput { + threadKey: string; + hasActionableProposedPlan: boolean; + hasPendingApprovals: boolean; + hasPendingUserInput: boolean; + interactionMode: SidebarThreadSummary["interactionMode"]; + latestTurn: ThreadStatusLatestTurnSnapshot | null; + session: ThreadStatusSessionSnapshot | null; +} + +export interface SidebarThreadRowSnapshot { + id: ThreadId; + environmentId: EnvironmentId; + projectId: ProjectId; + title: string; + branch: string | null; + worktreePath: string | null; +} + +export interface SidebarThreadMetaSnapshot { + activityTimestamp: string; + isRunning: boolean; +} + +interface ProjectThreadRenderEntry { + threadKey: string; + id: ThreadId; + environmentId: EnvironmentId; + projectId: LogicalProjectKey; + createdAt: string; + archivedAt: string | null; + updatedAt?: string | undefined; + latestUserMessageAt: string | null; +} + +export interface SidebarProjectRenderStateSnapshot { + hasOverflowingThreads: boolean; + hiddenThreadKeys: readonly string[]; + renderedThreadKeys: readonly string[]; + showEmptyThreadState: boolean; + shouldShowThreadPanel: boolean; +} + +const EMPTY_PROJECT_RENDER_STATE: SidebarProjectRenderStateSnapshot = { + hasOverflowingThreads: false, + hiddenThreadKeys: EMPTY_PROJECT_THREAD_KEYS, + renderedThreadKeys: EMPTY_PROJECT_THREAD_KEYS, + showEmptyThreadState: false, + shouldShowThreadPanel: false, +}; + +function resolveLogicalProjectKey( + summary: SidebarThreadSummary, + physicalToLogicalKey?: ReadonlyMap, +): LogicalProjectKey { + const physicalProjectKey = scopedProjectKey( + scopeProjectRef(summary.environmentId, summary.projectId), + ); + return physicalToLogicalKey?.get(physicalProjectKey) ?? physicalProjectKey; +} + +function buildProjectThreadRenderEntry( + summary: SidebarThreadSummary, + physicalToLogicalKey?: ReadonlyMap, +): ProjectThreadRenderEntry { + return { + threadKey: scopedThreadKey(scopeThreadRef(summary.environmentId, summary.id)), + id: summary.id, + environmentId: summary.environmentId, + projectId: resolveLogicalProjectKey(summary, physicalToLogicalKey), + createdAt: summary.createdAt, + archivedAt: summary.archivedAt, + updatedAt: summary.updatedAt, + latestUserMessageAt: summary.latestUserMessageAt, + }; +} + +function buildProjectThreadStatusInput(summary: SidebarThreadSummary): ProjectThreadStatusInput { + return { + threadKey: scopedThreadKey(scopeThreadRef(summary.environmentId, summary.id)), + hasActionableProposedPlan: summary.hasActionableProposedPlan, + hasPendingApprovals: summary.hasPendingApprovals, + hasPendingUserInput: summary.hasPendingUserInput, + interactionMode: summary.interactionMode, + latestTurn: summary.latestTurn + ? { + turnId: summary.latestTurn.turnId, + startedAt: summary.latestTurn.startedAt, + completedAt: summary.latestTurn.completedAt, + } + : null, + session: summary.session + ? { + orchestrationStatus: summary.session.orchestrationStatus, + activeTurnId: summary.session.activeTurnId, + status: summary.session.status, + } + : null, + }; +} + +function forEachProjectThreadSummary( + state: AppState, + memberProjectRefs: readonly ScopedProjectRef[], + visit: (summary: SidebarThreadSummary) => void, +): void { + if (memberProjectRefs.length === 0) { + return; + } + + for (const ref of memberProjectRefs) { + const environmentState = state.environmentStateById[ref.environmentId]; + if (!environmentState) { + continue; + } + const threadIds = environmentState.threadIdsByProjectId[ref.projectId] ?? []; + for (const threadId of threadIds) { + const summary = environmentState.sidebarThreadSummaryById[threadId]; + if (!summary) { + continue; + } + visit(summary); + } + } +} + +function collectProjectThreadEntries( + state: AppState, + memberProjectRefs: readonly ScopedProjectRef[], + physicalToLogicalKey?: ReadonlyMap, +): ProjectThreadRenderEntry[] { + const entries: ProjectThreadRenderEntry[] = []; + forEachProjectThreadSummary(state, memberProjectRefs, (summary) => { + entries.push(buildProjectThreadRenderEntry(summary, physicalToLogicalKey)); + }); + return entries; +} + +function collectProjectThreadStatusInputs( + state: AppState, + memberProjectRefs: readonly ScopedProjectRef[], +): ProjectThreadStatusInput[] { + const inputs: ProjectThreadStatusInput[] = []; + forEachProjectThreadSummary(state, memberProjectRefs, (summary) => { + inputs.push(buildProjectThreadStatusInput(summary)); + }); + return inputs; +} + +function projectThreadStatusInputsEqual( + left: ProjectThreadStatusInput | undefined, + right: ProjectThreadStatusInput | undefined, +): boolean { + return ( + left !== undefined && + right !== undefined && + left.threadKey === right.threadKey && + left.hasActionableProposedPlan === right.hasActionableProposedPlan && + left.hasPendingApprovals === right.hasPendingApprovals && + left.hasPendingUserInput === right.hasPendingUserInput && + left.interactionMode === right.interactionMode && + left.latestTurn?.turnId === right.latestTurn?.turnId && + left.latestTurn?.startedAt === right.latestTurn?.startedAt && + left.latestTurn?.completedAt === right.latestTurn?.completedAt && + left.session?.orchestrationStatus === right.session?.orchestrationStatus && + left.session?.activeTurnId === right.session?.activeTurnId && + left.session?.status === right.session?.status + ); +} + +function includeUpdatedSortFields( + sortOrder: SidebarProjectSortOrder | SidebarThreadSortOrder, +): boolean { + return sortOrder === "updated_at"; +} + +export function createSidebarProjectOrderingThreadSnapshotsSelector(input: { + physicalToLogicalKey: ReadonlyMap; + sortOrder: SidebarProjectSortOrder; +}): (state: AppState) => readonly SidebarProjectOrderingThreadSnapshot[] { + let previousResult: + | readonly SidebarProjectOrderingThreadSnapshot[] + | SidebarProjectOrderingThreadSnapshot[] = EMPTY_PROJECT_ORDERING_THREAD_SNAPSHOTS; + let previousEntries = new Map(); + + return (state) => { + if (input.sortOrder === "manual") { + previousEntries = new Map(); + previousResult = EMPTY_PROJECT_ORDERING_THREAD_SNAPSHOTS; + return previousResult; + } + + const watchUpdatedFields = includeUpdatedSortFields(input.sortOrder); + const nextEntries = new Map(); + const nextResult: SidebarProjectOrderingThreadSnapshot[] = []; + let changed = false; + + for (const [environmentId, environmentState] of Object.entries( + state.environmentStateById, + ) as Array<[EnvironmentId, EnvironmentState]>) { + for (const threadId of environmentState.threadIds) { + const summary = environmentState.sidebarThreadSummaryById[threadId]; + if (!summary || summary.environmentId !== environmentId || summary.archivedAt !== null) { + continue; + } + + const logicalProjectKey = resolveLogicalProjectKey(summary, input.physicalToLogicalKey); + const entryKey = `${environmentId}:${threadId}`; + const previousEntry = previousEntries.get(entryKey); + if ( + previousEntry && + previousEntry.id === summary.id && + previousEntry.environmentId === summary.environmentId && + previousEntry.projectId === logicalProjectKey && + previousEntry.createdAt === summary.createdAt && + previousEntry.archivedAt === summary.archivedAt && + (!watchUpdatedFields || + (previousEntry.updatedAt === summary.updatedAt && + previousEntry.latestUserMessageAt === summary.latestUserMessageAt)) + ) { + nextEntries.set(entryKey, previousEntry); + nextResult.push(previousEntry); + if (previousResult[nextResult.length - 1] !== previousEntry) { + changed = true; + } + continue; + } + + const snapshot: SidebarProjectOrderingThreadSnapshot = { + id: summary.id, + environmentId: summary.environmentId, + projectId: logicalProjectKey, + createdAt: summary.createdAt, + archivedAt: summary.archivedAt, + updatedAt: summary.updatedAt, + latestUserMessageAt: summary.latestUserMessageAt, + }; + nextEntries.set(entryKey, snapshot); + nextResult.push(snapshot); + changed = true; + } + } + + if (previousResult.length !== nextResult.length) { + changed = true; + } + + if (!changed) { + previousEntries = nextEntries; + return previousResult; + } + + previousEntries = nextEntries; + previousResult = nextResult.length === 0 ? EMPTY_PROJECT_ORDERING_THREAD_SNAPSHOTS : nextResult; + return previousResult; + }; +} + +export function createSidebarSortedProjectKeysSelector(input: { + physicalToLogicalKey: ReadonlyMap; + projects: ReadonlyArray<{ + projectKey: LogicalProjectKey; + name: string; + createdAt?: string | undefined; + updatedAt?: string | undefined; + }>; + sortOrder: SidebarProjectSortOrder; +}): (state: AppState) => readonly LogicalProjectKey[] { + let previousResult: readonly LogicalProjectKey[] = EMPTY_SORTED_PROJECT_KEYS; + const orderingThreadSelector = createSidebarProjectOrderingThreadSnapshotsSelector({ + physicalToLogicalKey: input.physicalToLogicalKey, + sortOrder: input.sortOrder, + }); + + return (state) => { + const manualProjectKeys = input.projects.map((project) => project.projectKey); + if (input.sortOrder === "manual") { + if (stringArraysEqual(previousResult, manualProjectKeys)) { + return previousResult; + } + previousResult = + manualProjectKeys.length === 0 ? EMPTY_SORTED_PROJECT_KEYS : manualProjectKeys; + return previousResult; + } + + const sortableProjects = input.projects.map((project) => ({ + id: project.projectKey, + name: project.name, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + })); + const sortedProjectKeys = sortProjectsForSidebar( + sortableProjects, + orderingThreadSelector(state), + input.sortOrder, + ).map((project) => project.id); + + if (stringArraysEqual(previousResult, sortedProjectKeys)) { + return previousResult; + } + + previousResult = sortedProjectKeys.length === 0 ? EMPTY_SORTED_PROJECT_KEYS : sortedProjectKeys; + return previousResult; + }; +} + +export function createSidebarSortedThreadKeysByLogicalProjectSelector(input: { + physicalToLogicalKey: ReadonlyMap; + threadSortOrder: SidebarThreadSortOrder; +}): (state: AppState) => ReadonlyMap { + let previousResult = EMPTY_SORTED_THREAD_KEYS_BY_LOGICAL_PROJECT; + + return (state) => { + const groupedEntries = new Map(); + for (const [environmentId, environmentState] of Object.entries( + state.environmentStateById, + ) as Array<[EnvironmentId, EnvironmentState]>) { + for (const threadId of environmentState.threadIds) { + const summary = environmentState.sidebarThreadSummaryById[threadId]; + if (!summary || summary.environmentId !== environmentId || summary.archivedAt !== null) { + continue; + } + + const logicalProjectKey = resolveLogicalProjectKey(summary, input.physicalToLogicalKey); + const projectEntries = groupedEntries.get(logicalProjectKey); + const entry = buildProjectThreadRenderEntry(summary, input.physicalToLogicalKey); + if (projectEntries) { + projectEntries.push(entry); + } else { + groupedEntries.set(logicalProjectKey, [entry]); + } + } + } + + const nextResult = new Map(); + let changed = previousResult.size !== groupedEntries.size; + + for (const [projectKey, entries] of groupedEntries) { + const nextThreadKeys = sortThreadsForSidebar(entries, input.threadSortOrder).map( + (thread) => thread.threadKey, + ); + const previousThreadKeys = previousResult.get(projectKey); + if (previousThreadKeys && stringArraysEqual(previousThreadKeys, nextThreadKeys)) { + nextResult.set(projectKey, previousThreadKeys); + continue; + } + + nextResult.set( + projectKey, + nextThreadKeys.length === 0 ? EMPTY_PROJECT_THREAD_KEYS : nextThreadKeys, + ); + changed = true; + } + + if (!changed) { + return previousResult; + } + + previousResult = + nextResult.size === 0 ? EMPTY_SORTED_THREAD_KEYS_BY_LOGICAL_PROJECT : nextResult; + return previousResult; + }; +} + +export function createSidebarThreadRowSnapshotSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => SidebarThreadRowSnapshot | undefined { + let previousResult: SidebarThreadRowSnapshot | undefined; + + return (state) => { + if (!ref) { + return undefined; + } + + const summary = + state.environmentStateById[ref.environmentId]?.sidebarThreadSummaryById[ref.threadId]; + if (!summary) { + return undefined; + } + + const nextResult: SidebarThreadRowSnapshot = { + id: summary.id, + environmentId: summary.environmentId, + projectId: summary.projectId, + title: summary.title, + branch: summary.branch, + worktreePath: summary.worktreePath ?? null, + }; + + if ( + previousResult && + previousResult.id === nextResult.id && + previousResult.environmentId === nextResult.environmentId && + previousResult.projectId === nextResult.projectId && + previousResult.title === nextResult.title && + previousResult.branch === nextResult.branch && + previousResult.worktreePath === nextResult.worktreePath + ) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + +export function createSidebarThreadStatusInputSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => ProjectThreadStatusInput | undefined { + let previousResult: ProjectThreadStatusInput | undefined; + + return (state) => { + if (!ref) { + return undefined; + } + + const summary = + state.environmentStateById[ref.environmentId]?.sidebarThreadSummaryById[ref.threadId]; + if (!summary) { + return undefined; + } + + const nextResult = buildProjectThreadStatusInput(summary); + + if (projectThreadStatusInputsEqual(previousResult, nextResult)) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + +export function createSidebarThreadMetaSnapshotSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => SidebarThreadMetaSnapshot | undefined { + let previousResult: SidebarThreadMetaSnapshot | undefined; + + return (state) => { + if (!ref) { + return undefined; + } + + const summary = + state.environmentStateById[ref.environmentId]?.sidebarThreadSummaryById[ref.threadId]; + if (!summary) { + return undefined; + } + + const nextResult: SidebarThreadMetaSnapshot = { + activityTimestamp: summary.updatedAt ?? summary.createdAt, + isRunning: summary.session?.status === "running" && summary.session.activeTurnId != null, + }; + + if ( + previousResult && + previousResult.activityTimestamp === nextResult.activityTimestamp && + previousResult.isRunning === nextResult.isRunning + ) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + +export function createSidebarActiveRouteProjectKeySelectorByRef( + ref: ScopedThreadRef | null | undefined, + physicalToLogicalKey: ReadonlyMap, +): (state: AppState) => LogicalProjectKey | null { + let previousResult: LogicalProjectKey | null = null; + + return (state) => { + if (!ref) { + previousResult = null; + return null; + } + + const summary = + state.environmentStateById[ref.environmentId]?.sidebarThreadSummaryById[ref.threadId]; + if (!summary) { + previousResult = null; + return null; + } + + const nextResult = resolveLogicalProjectKey(summary, physicalToLogicalKey); + if (previousResult === nextResult) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + +export function createSidebarProjectRenderStateSelector(input: { + activeRouteThreadKey: string | null; + isThreadListExpanded: boolean; + memberProjectRefs: readonly ScopedProjectRef[]; + physicalToLogicalKey?: ReadonlyMap; + projectExpanded: boolean; + previewLimit: number; + threadSortOrder: SidebarThreadSortOrder; +}): (state: AppState) => SidebarProjectRenderStateSnapshot { + let previousResult = EMPTY_PROJECT_RENDER_STATE; + + return (state) => { + const visibleProjectThreads = sortThreadsForSidebar( + collectProjectThreadEntries( + state, + input.memberProjectRefs, + input.physicalToLogicalKey, + ).filter((thread) => thread.archivedAt === null), + input.threadSortOrder, + ); + const pinnedCollapsedThread = + !input.projectExpanded && input.activeRouteThreadKey + ? (visibleProjectThreads.find( + (thread) => thread.threadKey === input.activeRouteThreadKey, + ) ?? null) + : null; + const shouldShowThreadPanel = input.projectExpanded || pinnedCollapsedThread !== null; + const hasOverflowingThreads = visibleProjectThreads.length > input.previewLimit; + const previewThreads = + input.isThreadListExpanded || !hasOverflowingThreads + ? visibleProjectThreads + : visibleProjectThreads.slice(0, input.previewLimit); + const renderedThreadKeys = pinnedCollapsedThread + ? [pinnedCollapsedThread.threadKey] + : previewThreads.map((thread) => thread.threadKey); + const renderedThreadKeySet = new Set(renderedThreadKeys); + const hiddenThreadKeys = visibleProjectThreads + .filter((thread) => !renderedThreadKeySet.has(thread.threadKey)) + .map((thread) => thread.threadKey); + const nextResult: SidebarProjectRenderStateSnapshot = { + hasOverflowingThreads, + hiddenThreadKeys: + hiddenThreadKeys.length === 0 ? EMPTY_PROJECT_THREAD_KEYS : hiddenThreadKeys, + renderedThreadKeys: + renderedThreadKeys.length === 0 ? EMPTY_PROJECT_THREAD_KEYS : renderedThreadKeys, + showEmptyThreadState: input.projectExpanded && visibleProjectThreads.length === 0, + shouldShowThreadPanel, + }; + + if ( + previousResult.hasOverflowingThreads === nextResult.hasOverflowingThreads && + previousResult.showEmptyThreadState === nextResult.showEmptyThreadState && + previousResult.shouldShowThreadPanel === nextResult.shouldShowThreadPanel && + stringArraysEqual(previousResult.renderedThreadKeys, nextResult.renderedThreadKeys) && + stringArraysEqual(previousResult.hiddenThreadKeys, nextResult.hiddenThreadKeys) + ) { + return previousResult; + } + + previousResult = nextResult; + return nextResult; + }; +} + +export function createSidebarProjectThreadStatusInputsSelector( + memberProjectRefs: readonly ScopedProjectRef[], +): (state: AppState) => readonly ProjectThreadStatusInput[] { + let previousResult: readonly ProjectThreadStatusInput[] = EMPTY_PROJECT_THREAD_STATUS_INPUTS; + + return (state) => { + const nextInputs = collectProjectThreadStatusInputs(state, memberProjectRefs); + if ( + previousResult.length === nextInputs.length && + previousResult.every((previousInput, index) => { + const nextInput = nextInputs[index]; + return nextInput !== undefined && projectThreadStatusInputsEqual(previousInput, nextInput); + }) + ) { + return previousResult; + } + + previousResult = nextInputs.length === 0 ? EMPTY_PROJECT_THREAD_STATUS_INPUTS : nextInputs; + return previousResult; + }; +} diff --git a/apps/web/src/components/sidebar/sidebarViewStore.ts b/apps/web/src/components/sidebar/sidebarViewStore.ts new file mode 100644 index 0000000000..9e3d96b64b --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarViewStore.ts @@ -0,0 +1,126 @@ +import { useCallback } from "react"; +import { useStore as useZustandStore } from "zustand"; +import { createStore } from "zustand/vanilla"; +import type { LogicalProjectKey } from "../../logicalProject"; + +interface SidebarTransientState { + activeRouteThreadKey: string | null; + activeRouteProjectKey: LogicalProjectKey | null; + threadJumpLabelByKey: ReadonlyMap; + expandedThreadListsByProject: ReadonlySet; +} + +const EMPTY_THREAD_JUMP_LABELS = new Map(); +const EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT = new Set(); + +const sidebarViewStore = createStore(() => ({ + activeRouteThreadKey: null, + activeRouteProjectKey: null, + threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, + expandedThreadListsByProject: EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT, +})); + +export function resetSidebarViewState(): void { + sidebarViewStore.setState({ + activeRouteThreadKey: null, + activeRouteProjectKey: null, + threadJumpLabelByKey: EMPTY_THREAD_JUMP_LABELS, + expandedThreadListsByProject: EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT, + }); +} + +export function useSidebarProjectThreadListExpanded(projectKey: LogicalProjectKey): boolean { + return useZustandStore( + sidebarViewStore, + useCallback( + (state: SidebarTransientState) => state.expandedThreadListsByProject.has(projectKey), + [projectKey], + ), + ); +} + +export function useSidebarExpandedThreadListsByProject(): ReadonlySet { + return useZustandStore(sidebarViewStore, (state) => state.expandedThreadListsByProject); +} + +export function expandSidebarProjectThreadList(projectKey: LogicalProjectKey): void { + const { expandedThreadListsByProject } = sidebarViewStore.getState(); + if (expandedThreadListsByProject.has(projectKey)) { + return; + } + + sidebarViewStore.setState({ + expandedThreadListsByProject: new Set([...expandedThreadListsByProject, projectKey]), + }); +} + +export function collapseSidebarProjectThreadList(projectKey: LogicalProjectKey): void { + const { expandedThreadListsByProject } = sidebarViewStore.getState(); + if (!expandedThreadListsByProject.has(projectKey)) { + return; + } + + const nextExpandedThreadListsByProject = new Set(expandedThreadListsByProject); + nextExpandedThreadListsByProject.delete(projectKey); + sidebarViewStore.setState({ + expandedThreadListsByProject: + nextExpandedThreadListsByProject.size === 0 + ? EMPTY_EXPANDED_THREAD_LISTS_BY_PROJECT + : nextExpandedThreadListsByProject, + }); +} + +export function useSidebarIsActiveThread(threadKey: string): boolean { + return useZustandStore( + sidebarViewStore, + useCallback( + (state: SidebarTransientState) => state.activeRouteThreadKey === threadKey, + [threadKey], + ), + ); +} + +export function useSidebarThreadJumpLabel(threadKey: string): string | null { + return useZustandStore( + sidebarViewStore, + useCallback( + (state: SidebarTransientState) => state.threadJumpLabelByKey.get(threadKey) ?? null, + [threadKey], + ), + ); +} + +export function useSidebarProjectActiveRouteThreadKey( + projectKey: LogicalProjectKey, +): string | null { + return useZustandStore( + sidebarViewStore, + useCallback( + (state: SidebarTransientState) => { + return state.activeRouteProjectKey === projectKey ? state.activeRouteThreadKey : null; + }, + [projectKey], + ), + ); +} + +export function setSidebarKeyboardState(input: { + activeRouteProjectKey: LogicalProjectKey | null; + activeRouteThreadKey: string | null; + threadJumpLabelByKey: ReadonlyMap; +}): void { + const currentState = sidebarViewStore.getState(); + if ( + currentState.activeRouteThreadKey === input.activeRouteThreadKey && + currentState.activeRouteProjectKey === input.activeRouteProjectKey && + currentState.threadJumpLabelByKey === input.threadJumpLabelByKey + ) { + return; + } + + sidebarViewStore.setState({ + activeRouteThreadKey: input.activeRouteThreadKey, + activeRouteProjectKey: input.activeRouteProjectKey, + threadJumpLabelByKey: input.threadJumpLabelByKey, + }); +} diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index 5317a5fc68..f89f2293e2 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -122,7 +122,7 @@ describe("estimateTimelineMessageHeight", () => { text: "a".repeat(20), }; - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(184); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(162); expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(118); }); diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 3cb2aebb88..4c89832710 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -17,13 +17,20 @@ const USER_ATTACHMENT_ROW_HEIGHT_PX = 116; const USER_BUBBLE_WIDTH_RATIO = 0.8; const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; -const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; +const USER_MONO_AVG_CHAR_WIDTH_PX = 6.7; const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; const MIN_USER_CHARS_PER_LINE = 4; const MIN_ASSISTANT_CHARS_PER_LINE = 20; const ASSISTANT_INLINE_CODE_WIDTH_MULTIPLIER = 1.2; const ASSISTANT_INLINE_CODE_WRAP_OVERHEAD_CHARS = 2; const INLINE_CODE_SPAN_REGEX = /`([^`\n]+)`/g; +const USER_MESSAGE_BUBBLE_PROBE_CLASS = + "relative rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"; +const USER_MESSAGE_TEXT_PROBE_CLASS = + "whitespace-pre-wrap wrap-break-word text-sm leading-relaxed text-foreground"; +const USER_MESSAGE_META_PROBE_CLASS = "mt-1.5 flex items-center justify-end gap-2"; +const USER_MESSAGE_TIMESTAMP_PROBE_CLASS = "text-right text-xs text-muted-foreground/50"; +const USER_MESSAGE_CHAR_WIDTH_SAMPLE = "x".repeat(512); interface TimelineMessageHeightInput { role: "user" | "assistant" | "system"; @@ -35,6 +42,134 @@ interface TimelineHeightEstimateLayout { timelineWidthPx: number | null; } +interface BrowserUserMessageMetrics { + avgCharWidthPx: number; + baseHeightPx: number; + bubbleHorizontalChromePx: number; + lineHeightPx: number; +} + +interface BrowserUserMessageProbe { + host: HTMLDivElement; + bubble: HTMLDivElement; + text: HTMLDivElement; + meta: HTMLDivElement; + timestamp: HTMLParagraphElement; + widthSample: HTMLSpanElement; +} + +let browserUserMessageProbe: BrowserUserMessageProbe | null = null; +let cachedBrowserUserMessageMetrics: BrowserUserMessageMetrics | null = null; + +function canUseBrowserTypographyProbe(): boolean { + return typeof document !== "undefined" && document.body instanceof HTMLBodyElement; +} + +function createBrowserUserMessageProbe(): BrowserUserMessageProbe | null { + if (!canUseBrowserTypographyProbe()) { + return null; + } + + const host = document.createElement("div"); + host.setAttribute("aria-hidden", "true"); + host.style.position = "fixed"; + host.style.left = "-10000px"; + host.style.top = "0"; + host.style.width = "1200px"; + host.style.visibility = "hidden"; + host.style.pointerEvents = "none"; + host.style.contain = "layout style"; + + const bubble = document.createElement("div"); + bubble.className = USER_MESSAGE_BUBBLE_PROBE_CLASS; + bubble.style.maxWidth = "80%"; + + const text = document.createElement("div"); + text.className = USER_MESSAGE_TEXT_PROBE_CLASS; + + const meta = document.createElement("div"); + meta.className = USER_MESSAGE_META_PROBE_CLASS; + + const timestamp = document.createElement("p"); + timestamp.className = USER_MESSAGE_TIMESTAMP_PROBE_CLASS; + timestamp.textContent = "12:00 PM"; + + const widthSample = document.createElement("span"); + widthSample.className = USER_MESSAGE_TEXT_PROBE_CLASS; + widthSample.style.display = "inline-block"; + widthSample.style.whiteSpace = "pre"; + widthSample.textContent = USER_MESSAGE_CHAR_WIDTH_SAMPLE; + + meta.append(timestamp); + bubble.append(text, meta, widthSample); + host.append(bubble); + document.body.append(host); + + return { host, bubble, text, meta, timestamp, widthSample }; +} + +function shouldCacheBrowserUserMessageMetrics(): boolean { + return typeof document.fonts === "undefined" || document.fonts.status === "loaded"; +} + +function getBrowserUserMessageProbe(): BrowserUserMessageProbe | null { + if (browserUserMessageProbe?.host.isConnected) { + return browserUserMessageProbe; + } + browserUserMessageProbe = createBrowserUserMessageProbe(); + return browserUserMessageProbe; +} + +function getComputedStyleNumber(style: CSSStyleDeclaration, property: string): number | null { + const raw = style.getPropertyValue(property); + if (raw.length === 0) { + return null; + } + const parsed = Number.parseFloat(raw); + return Number.isFinite(parsed) ? parsed : null; +} + +function getBrowserUserMessageMetrics(): BrowserUserMessageMetrics | null { + if (cachedBrowserUserMessageMetrics) { + return cachedBrowserUserMessageMetrics; + } + + const probe = getBrowserUserMessageProbe(); + if (!probe) { + return null; + } + + probe.text.textContent = "x"; + const bubbleStyle = window.getComputedStyle(probe.bubble); + const textStyle = window.getComputedStyle(probe.text); + const lineHeightPx = getComputedStyleNumber(textStyle, "line-height") ?? USER_LINE_HEIGHT_PX; + const paddingLeftPx = getComputedStyleNumber(bubbleStyle, "padding-left") ?? 16; + const paddingRightPx = getComputedStyleNumber(bubbleStyle, "padding-right") ?? 16; + const borderLeftPx = getComputedStyleNumber(bubbleStyle, "border-left-width") ?? 1; + const borderRightPx = getComputedStyleNumber(bubbleStyle, "border-right-width") ?? 1; + const bubbleHorizontalChromePx = paddingLeftPx + paddingRightPx + borderLeftPx + borderRightPx; + const baseHeightPx = Math.max(0, probe.bubble.getBoundingClientRect().height - lineHeightPx); + const avgCharWidthPx = + probe.widthSample.getBoundingClientRect().width / USER_MESSAGE_CHAR_WIDTH_SAMPLE.length; + + if (!Number.isFinite(baseHeightPx) || !Number.isFinite(avgCharWidthPx) || avgCharWidthPx <= 0) { + return null; + } + + const metrics = { + avgCharWidthPx, + baseHeightPx, + bubbleHorizontalChromePx, + lineHeightPx, + }; + + if (shouldCacheBrowserUserMessageMetrics()) { + cachedBrowserUserMessageMetrics = metrics; + } + + return metrics; +} + function estimateWrappedLineCount(text: string, charsPerLine: number): number { if (text.length === 0) return 1; @@ -60,6 +195,15 @@ function isFinitePositiveNumber(value: number | null | undefined): value is numb function estimateCharsPerLineForUser(timelineWidthPx: number | null): number { if (!isFinitePositiveNumber(timelineWidthPx)) return USER_CHARS_PER_LINE_FALLBACK; + const browserMetrics = getBrowserUserMessageMetrics(); + if (browserMetrics) { + const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO; + const textWidthPx = Math.max(bubbleWidthPx - browserMetrics.bubbleHorizontalChromePx, 0); + return Math.max( + MIN_USER_CHARS_PER_LINE, + Math.floor(textWidthPx / browserMetrics.avgCharWidthPx), + ); + } const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO; const textWidthPx = Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_MONO_AVG_CHAR_WIDTH_PX)); @@ -103,6 +247,7 @@ export function estimateTimelineMessageHeight( if (message.role === "user") { const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); + const browserMetrics = getBrowserUserMessageMetrics(); const displayedUserMessage = deriveDisplayedUserMessageState(message.text); const renderedText = displayedUserMessage.contexts.length > 0 @@ -117,7 +262,11 @@ export function estimateTimelineMessageHeight( const attachmentCount = message.attachments?.length ?? 0; const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; - return USER_BASE_HEIGHT_PX + estimatedLines * USER_LINE_HEIGHT_PX + attachmentHeight; + return ( + (browserMetrics?.baseHeightPx ?? USER_BASE_HEIGHT_PX) + + estimatedLines * (browserMetrics?.lineHeightPx ?? USER_LINE_HEIGHT_PX) + + attachmentHeight + ); } // `system` messages are not rendered in the chat timeline, but keep a stable diff --git a/apps/web/src/hooks/useTurnDiffSummaries.ts b/apps/web/src/hooks/useTurnDiffSummaries.ts index 2bf72c96cc..96c974945a 100644 --- a/apps/web/src/hooks/useTurnDiffSummaries.ts +++ b/apps/web/src/hooks/useTurnDiffSummaries.ts @@ -1,15 +1,8 @@ import { useMemo } from "react"; import { inferCheckpointTurnCountByTurnId } from "../session-logic"; -import type { Thread } from "../types"; - -export function useTurnDiffSummaries(activeThread: Thread | undefined) { - const turnDiffSummaries = useMemo(() => { - if (!activeThread) { - return []; - } - return activeThread.turnDiffSummaries; - }, [activeThread]); +import type { TurnDiffSummary } from "../types"; +export function useTurnDiffSummaries(turnDiffSummaries: readonly TurnDiffSummary[]) { const inferredCheckpointTurnCountByTurnId = useMemo( () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), [turnDiffSummaries], diff --git a/apps/web/src/lib/gitStatusState.ts b/apps/web/src/lib/gitStatusState.ts index 2c23ae5b82..d1ac85830c 100644 --- a/apps/web/src/lib/gitStatusState.ts +++ b/apps/web/src/lib/gitStatusState.ts @@ -61,6 +61,10 @@ const gitStatusLastRefreshAtByKey = new Map(); const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000; +function selectIsRepo(state: GitStatusState): boolean { + return state.data?.isRepo ?? true; +} + const gitStatusStateAtom = Atom.family((key: string) => { knownGitStatusKeys.add(key); return Atom.make(INITIAL_GIT_STATUS_STATE).pipe( @@ -175,6 +179,19 @@ export function useGitStatus(target: GitStatusTarget): GitStatusState { return targetKey === null ? EMPTY_GIT_STATUS_STATE : state; } +export function useGitStatusIsRepo(target: GitStatusTarget): boolean { + const targetKey = getGitStatusTargetKey(target); + useEffect( + () => watchGitStatus({ environmentId: target.environmentId, cwd: target.cwd }), + [target.environmentId, target.cwd], + ); + + return useAtomValue( + targetKey !== null ? gitStatusStateAtom(targetKey) : EMPTY_GIT_STATUS_ATOM, + selectIsRepo, + ); +} + function unwatchGitStatus(targetKey: string): void { const watched = watchedGitStatuses.get(targetKey); if (!watched) { diff --git a/apps/web/src/lib/providerReactQuery.test.ts b/apps/web/src/lib/providerReactQuery.test.ts index 85466326e4..1329f8d78a 100644 --- a/apps/web/src/lib/providerReactQuery.test.ts +++ b/apps/web/src/lib/providerReactQuery.test.ts @@ -141,6 +141,28 @@ describe("checkpointDiffQueryOptions", () => { expect(retry(3, new Error("Something else failed."))).toBe(false); }); + it("retries transient checkpoint errors for full thread diffs too", () => { + const options = checkpointDiffQueryOptions({ + environmentId, + threadId, + fromTurnCount: 0, + toTurnCount: 2, + cacheScope: "thread:all", + }); + const retry = options.retry; + expect(typeof retry).toBe("function"); + if (typeof retry !== "function") { + throw new Error("Expected retry to be a function."); + } + + expect( + retry(1, new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1.")), + ).toBe(true); + expect( + retry(12, new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1.")), + ).toBe(false); + }); + it("backs off longer for checkpoint-not-ready errors", () => { const options = checkpointDiffQueryOptions({ environmentId, diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index 789441877b..3d4889b838 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -2,9 +2,11 @@ import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; import type { ScopedProjectRef } from "@t3tools/contracts"; import type { Project } from "./types"; +export type LogicalProjectKey = string; + export function deriveLogicalProjectKey( project: Pick, -): string { +): LogicalProjectKey { return ( project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) @@ -14,6 +16,6 @@ export function deriveLogicalProjectKey( export function deriveLogicalProjectKeyFromRef( projectRef: ScopedProjectRef, project: Pick | null | undefined, -): string { +): LogicalProjectKey { return project?.repositoryIdentity?.canonicalKey ?? scopedProjectKey(projectRef); } diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index b62fd6b9c6..8f367defb7 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -2,7 +2,7 @@ import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/reac import { Suspense, lazy, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import ChatView from "../components/ChatView"; -import { threadHasStarted } from "../components/ChatView.logic"; +import { threadSnapshotHasStarted } from "../components/ChatView.logic"; import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; import { DiffPanelHeaderSkeleton, @@ -18,7 +18,10 @@ import { } from "../diffRouteSearch"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; -import { createThreadSelectorByRef } from "../storeSelectors"; +import { + createThreadConversationRuntimeSelectorByRef, + createThreadMessageIdsSelectorByRef, +} from "../storeSelectors"; import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; import { Sheet, SheetPopup } from "../components/ui/sheet"; import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; @@ -171,7 +174,12 @@ function ChatThreadRouteView() { const bootstrapComplete = useStore( (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, ); - const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const serverThreadRuntime = useStore( + useMemo(() => createThreadConversationRuntimeSelectorByRef(threadRef), [threadRef]), + ); + const serverMessageIds = useStore( + useMemo(() => createThreadMessageIdsSelectorByRef(threadRef), [threadRef]), + ); const threadExists = useStore((store) => selectThreadExistsByRef(store, threadRef)); const environmentHasServerThreads = useStore( (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0, @@ -189,7 +197,11 @@ function ChatThreadRouteView() { return store.hasDraftThreadsInEnvironment(threadRef.environmentId); }); const routeThreadExists = threadExists || draftThreadExists; - const serverThreadStarted = threadHasStarted(serverThread); + const serverThreadStarted = threadSnapshotHasStarted({ + latestTurn: serverThreadRuntime?.latestTurn ?? null, + session: serverThreadRuntime?.session ?? null, + messageCount: serverMessageIds.length, + }); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; const diffOpen = search.diff === "1"; const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 54e84c883a..2427ecfd04 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1058,7 +1058,7 @@ export function deriveCompletionDividerBeforeEntryId( } export function inferCheckpointTurnCountByTurnId( - summaries: TurnDiffSummary[], + summaries: readonly TurnDiffSummary[], ): Record { const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt)); const result: Record = {}; diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index 02b88f31b1..d9f08ef8b1 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,8 +1,408 @@ -import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts"; +import { + type ApprovalRequestId, + type MessageId, + type ScopedProjectRef, + type ScopedThreadRef, + type ThreadId, + type TurnId, +} from "@t3tools/contracts"; import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store"; -import { type Project, type SidebarThreadSummary, type Thread } from "./types"; +import { + type ChatMessage, + type Project, + type ProposedPlan, + type SidebarThreadSummary, + type Thread, + type ThreadShell, + type ThreadSession, + type ThreadTurnState, + type TurnDiffSummary, +} from "./types"; +import { + type ActivePlanState, + type LatestProposedPlanState, + type PendingApproval, + type PendingUserInput, + type WorkLogEntry, + deriveActivePlanState, + derivePendingApprovals, + derivePendingUserInputs, + derivePhase, + deriveWorkLogEntries, + findLatestProposedPlan, + hasToolActivityForTurn, + isLatestTurnSettled, +} from "./session-logic"; import { getThreadFromEnvironmentState } from "./threadDerivation"; +const EMPTY_MESSAGES: ChatMessage[] = []; +const EMPTY_MESSAGE_IDS: readonly MessageId[] = []; +const EMPTY_ACTIVITIES: Thread["activities"] = []; +const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; +const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; +const EMPTY_WORK_LOG_ENTRIES: WorkLogEntry[] = []; +const EMPTY_THREAD_PENDING_SNAPSHOT: ThreadPendingSnapshot = { + pendingApprovalRequestId: null, + pendingUserInputRequestId: null, +}; +const EMPTY_THREAD_TIMELINE_SLICE_SNAPSHOT: ThreadTimelineSliceSnapshot = { + historicalMessages: EMPTY_MESSAGES, + liveMessages: EMPTY_MESSAGES, + historicalProposedPlans: EMPTY_PROPOSED_PLANS, + liveProposedPlans: EMPTY_PROPOSED_PLANS, + turnDiffSummaries: EMPTY_TURN_DIFF_SUMMARIES, + activeWorkEntries: EMPTY_WORK_LOG_ENTRIES, + latestTurnHasToolActivity: false, +}; + +export type ThreadStaticShellSnapshot = Pick< + ThreadShell, + | "id" + | "environmentId" + | "projectId" + | "title" + | "modelSelection" + | "runtimeMode" + | "interactionMode" + | "error" + | "createdAt" + | "branch" + | "worktreePath" +>; + +export interface ThreadRuntimeSnapshot { + session: Pick< + ThreadSession, + "provider" | "status" | "activeTurnId" | "createdAt" | "updatedAt" | "orchestrationStatus" + > | null; + latestTurn: Pick< + NonNullable, + | "turnId" + | "state" + | "requestedAt" + | "startedAt" + | "completedAt" + | "assistantMessageId" + | "sourceProposedPlan" + > | null; + pendingSourceProposedPlan?: ThreadTurnState["pendingSourceProposedPlan"]; + phase: ReturnType; +} + +export interface ThreadConversationRuntimeSnapshot { + session: Pick< + ThreadSession, + "provider" | "status" | "activeTurnId" | "orchestrationStatus" + > | null; + latestTurn: Pick< + NonNullable, + | "turnId" + | "state" + | "requestedAt" + | "startedAt" + | "completedAt" + | "assistantMessageId" + | "sourceProposedPlan" + > | null; + phase: ReturnType; +} + +export interface ThreadPendingSnapshot { + pendingApprovalRequestId: ApprovalRequestId | null; + pendingUserInputRequestId: ApprovalRequestId | null; +} + +export interface ThreadComposerSnapshot { + session: ThreadConversationRuntimeSnapshot["session"]; + latestTurn: ThreadConversationRuntimeSnapshot["latestTurn"]; + phase: ReturnType; + latestTurnSettled: boolean; + pendingApprovals: PendingApproval[]; + pendingUserInputs: PendingUserInput[]; + activeProposedPlan: LatestProposedPlanState | null; + activePlan: ActivePlanState | null; +} + +export interface ThreadTimelineSliceSnapshot { + historicalMessages: ChatMessage[]; + liveMessages: ChatMessage[]; + historicalProposedPlans: ProposedPlan[]; + liveProposedPlans: ProposedPlan[]; + turnDiffSummaries: TurnDiffSummary[]; + activeWorkEntries: WorkLogEntry[]; + latestTurnHasToolActivity: boolean; +} + +function collectByIds( + ids: readonly TKey[] | undefined, + byId: Record | undefined, +): TValue[] { + if (!ids || ids.length === 0 || !byId) { + return []; + } + + return ids.flatMap((id) => { + const value = byId[id]; + return value ? [value] : []; + }); +} + +function shallowArrayEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function normalizeArrayResult(values: TValue[], emptyValue: TValue[]): TValue[] { + return values.length === 0 ? emptyValue : values; +} + +function latestTurnSnapshotsEqual( + left: ThreadConversationRuntimeSnapshot["latestTurn"], + right: ThreadConversationRuntimeSnapshot["latestTurn"], +): boolean { + return ( + left?.turnId === right?.turnId && + left?.state === right?.state && + left?.requestedAt === right?.requestedAt && + left?.startedAt === right?.startedAt && + left?.completedAt === right?.completedAt && + left?.assistantMessageId === right?.assistantMessageId && + left?.sourceProposedPlan === right?.sourceProposedPlan + ); +} + +function conversationSessionSnapshotsEqual( + left: ThreadConversationRuntimeSnapshot["session"], + right: ThreadConversationRuntimeSnapshot["session"], +): boolean { + return ( + left?.provider === right?.provider && + left?.status === right?.status && + left?.activeTurnId === right?.activeTurnId && + left?.orchestrationStatus === right?.orchestrationStatus + ); +} + +function runtimeSessionSnapshotsEqual( + left: ThreadRuntimeSnapshot["session"], + right: ThreadRuntimeSnapshot["session"], +): boolean { + return ( + conversationSessionSnapshotsEqual(left, right) && + left?.createdAt === right?.createdAt && + left?.updatedAt === right?.updatedAt + ); +} + +function toLatestTurnSnapshot( + latestTurn: ThreadTurnState["latestTurn"], +): ThreadConversationRuntimeSnapshot["latestTurn"] { + return latestTurn + ? { + turnId: latestTurn.turnId, + state: latestTurn.state, + requestedAt: latestTurn.requestedAt, + startedAt: latestTurn.startedAt, + completedAt: latestTurn.completedAt, + assistantMessageId: latestTurn.assistantMessageId, + sourceProposedPlan: latestTurn.sourceProposedPlan, + } + : null; +} + +function toConversationSessionSnapshot( + session: ThreadSession | null, +): ThreadConversationRuntimeSnapshot["session"] { + return session + ? { + provider: session.provider, + status: session.status, + activeTurnId: session.activeTurnId, + orchestrationStatus: session.orchestrationStatus, + } + : null; +} + +function toRuntimeSessionSnapshot(session: ThreadSession | null): ThreadRuntimeSnapshot["session"] { + return session + ? { + provider: session.provider, + status: session.status, + activeTurnId: session.activeTurnId, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + orchestrationStatus: session.orchestrationStatus, + } + : null; +} + +function resolveThreadState( + state: AppState, + ref: ScopedThreadRef | null | undefined, +): { + session: ThreadSession | null; + turnState: ThreadTurnState | undefined; +} | null { + if (!ref) { + return null; + } + + const environmentState = selectEnvironmentState(state, ref.environmentId); + if (!environmentState.threadShellById[ref.threadId]) { + return null; + } + + return { + session: environmentState.threadSessionById[ref.threadId] ?? null, + turnState: environmentState.threadTurnStateById[ref.threadId], + }; +} + +function pendingApprovalsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((approval, index) => { + const candidate = right[index]; + return ( + candidate !== undefined && + approval.requestId === candidate.requestId && + approval.requestKind === candidate.requestKind && + approval.createdAt === candidate.createdAt && + approval.detail === candidate.detail + ); + }) + ); +} + +function pendingUserInputsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((userInput, index) => { + const candidate = right[index]; + return ( + candidate !== undefined && + userInput.requestId === candidate.requestId && + userInput.createdAt === candidate.createdAt && + userInputQuestionsEqual(userInput.questions, candidate.questions) + ); + }) + ); +} + +function userInputQuestionsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((question, index) => { + const candidate = right[index]; + return ( + candidate !== undefined && + question.id === candidate.id && + question.header === candidate.header && + question.question === candidate.question && + question.multiSelect === candidate.multiSelect && + userInputOptionsEqual(question.options, candidate.options) + ); + }) + ); +} + +function userInputOptionsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((option, index) => { + const candidate = right[index]; + return ( + candidate !== undefined && + option.label === candidate.label && + option.description === candidate.description + ); + }) + ); +} + +function activePlanStatesEqual( + left: ActivePlanState | null, + right: ActivePlanState | null, +): boolean { + if (left === right) { + return true; + } + if (!left || !right) { + return false; + } + return ( + left.createdAt === right.createdAt && + left.turnId === right.turnId && + left.explanation === right.explanation && + left.steps.length === right.steps.length && + left.steps.every( + (step, index) => + step.step === right.steps[index]?.step && step.status === right.steps[index]?.status, + ) + ); +} + +function latestProposedPlansEqual( + left: LatestProposedPlanState | null, + right: LatestProposedPlanState | null, +): boolean { + if (left === right) { + return true; + } + if (!left || !right) { + return false; + } + return ( + left.id === right.id && + left.createdAt === right.createdAt && + left.updatedAt === right.updatedAt && + left.turnId === right.turnId && + left.planMarkdown === right.planMarkdown && + left.implementedAt === right.implementedAt && + left.implementationThreadId === right.implementationThreadId + ); +} + +function workLogEntriesEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((entry, index) => { + const candidate = right[index]; + return ( + candidate !== undefined && + entry.id === candidate.id && + entry.createdAt === candidate.createdAt && + entry.label === candidate.label && + entry.tone === candidate.tone && + entry.detail === candidate.detail && + entry.command === candidate.command && + entry.rawCommand === candidate.rawCommand && + entry.toolTitle === candidate.toolTitle && + entry.itemType === candidate.itemType && + entry.requestKind === candidate.requestKind && + shallowArrayEqual(entry.changedFiles ?? [], candidate.changedFiles ?? []) + ); + }) + ); +} + export function createProjectSelectorByRef( ref: ScopedProjectRef | null | undefined, ): (state: AppState) => Project | undefined { @@ -19,6 +419,701 @@ export function createSidebarThreadSummarySelectorByRef( : undefined; } +export type ThreadBranchToolbarSnapshot = Pick< + ThreadShell, + "environmentId" | "projectId" | "worktreePath" +>; + +export type ThreadWorkspaceSnapshot = ThreadBranchToolbarSnapshot; + +export type ThreadBranchActionSnapshot = Pick< + ThreadShell, + "id" | "environmentId" | "projectId" | "branch" | "worktreePath" +> & { + hasSession: boolean; +}; + +export function createThreadBranchToolbarSnapshotSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => ThreadWorkspaceSnapshot | undefined { + let previousShell: EnvironmentState["threadShellById"][ThreadId] | undefined; + let previousResult: ThreadWorkspaceSnapshot | undefined; + + return (state) => { + if (!ref) { + previousShell = undefined; + previousResult = undefined; + return undefined; + } + + const environmentState = selectEnvironmentState(state, ref.environmentId); + const shell = environmentState.threadShellById[ref.threadId]; + if (!shell) { + previousShell = undefined; + previousResult = undefined; + return undefined; + } + + if ( + previousResult && + previousShell?.environmentId === shell.environmentId && + previousShell.projectId === shell.projectId && + previousShell.worktreePath === shell.worktreePath + ) { + previousShell = shell; + return previousResult; + } + + previousShell = shell; + previousResult = { + environmentId: shell.environmentId, + projectId: shell.projectId, + worktreePath: shell.worktreePath, + }; + return previousResult; + }; +} + +export function createThreadBranchActionSnapshotSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => ThreadBranchActionSnapshot | undefined { + let previousShell: EnvironmentState["threadShellById"][ThreadId] | undefined; + let previousSession: ThreadSession | null | undefined; + let previousResult: ThreadBranchActionSnapshot | undefined; + + return (state) => { + if (!ref) { + previousShell = undefined; + previousSession = undefined; + previousResult = undefined; + return undefined; + } + + const environmentState = selectEnvironmentState(state, ref.environmentId); + const shell = environmentState.threadShellById[ref.threadId]; + if (!shell) { + previousShell = undefined; + previousSession = undefined; + previousResult = undefined; + return undefined; + } + + const session = environmentState.threadSessionById[ref.threadId] ?? null; + const hasSession = session !== null; + + if ( + previousResult && + previousShell?.id === shell.id && + previousShell.environmentId === shell.environmentId && + previousShell.projectId === shell.projectId && + previousShell.branch === shell.branch && + previousShell.worktreePath === shell.worktreePath && + (previousSession !== null) === hasSession + ) { + previousShell = shell; + previousSession = session; + return previousResult; + } + + previousShell = shell; + previousSession = session; + previousResult = { + id: shell.id, + environmentId: shell.environmentId, + projectId: shell.projectId, + branch: shell.branch, + worktreePath: shell.worktreePath, + hasSession, + }; + return previousResult; + }; +} + +export function createThreadStaticShellSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => ThreadStaticShellSnapshot | undefined { + let previousShell: EnvironmentState["threadShellById"][ThreadId] | undefined; + let previousResult: ThreadStaticShellSnapshot | undefined; + + return (state) => { + if (!ref) { + previousShell = undefined; + previousResult = undefined; + return undefined; + } + + const environmentState = selectEnvironmentState(state, ref.environmentId); + const shell = environmentState.threadShellById[ref.threadId]; + if (!shell) { + previousShell = undefined; + previousResult = undefined; + return undefined; + } + + if ( + previousResult && + previousShell?.id === shell.id && + previousShell.environmentId === shell.environmentId && + previousShell.projectId === shell.projectId && + previousShell.title === shell.title && + previousShell.modelSelection === shell.modelSelection && + previousShell.runtimeMode === shell.runtimeMode && + previousShell.interactionMode === shell.interactionMode && + previousShell.error === shell.error && + previousShell.createdAt === shell.createdAt && + previousShell.branch === shell.branch && + previousShell.worktreePath === shell.worktreePath + ) { + previousShell = shell; + return previousResult; + } + + previousShell = shell; + previousResult = { + id: shell.id, + environmentId: shell.environmentId, + projectId: shell.projectId, + title: shell.title, + modelSelection: shell.modelSelection, + runtimeMode: shell.runtimeMode, + interactionMode: shell.interactionMode, + error: shell.error, + createdAt: shell.createdAt, + branch: shell.branch, + worktreePath: shell.worktreePath, + }; + return previousResult; + }; +} + +export function createThreadRuntimeSnapshotSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => ThreadRuntimeSnapshot | undefined { + let previousResult: ThreadRuntimeSnapshot | undefined; + + return (state) => { + const resolved = resolveThreadState(state, ref); + if (!resolved) { + previousResult = undefined; + return undefined; + } + + const { session, turnState } = resolved; + const nextSession = toRuntimeSessionSnapshot(session); + const nextLatestTurn = toLatestTurnSnapshot(turnState?.latestTurn ?? null); + const phase = derivePhase(session); + const pendingSourceProposedPlan = turnState?.pendingSourceProposedPlan; + + if ( + previousResult && + runtimeSessionSnapshotsEqual(previousResult.session, nextSession) && + latestTurnSnapshotsEqual(previousResult.latestTurn, nextLatestTurn) && + previousResult.pendingSourceProposedPlan === pendingSourceProposedPlan && + previousResult.phase === phase + ) { + return previousResult; + } + + previousResult = { + session: + previousResult && runtimeSessionSnapshotsEqual(previousResult.session, nextSession) + ? previousResult.session + : nextSession, + latestTurn: + previousResult && latestTurnSnapshotsEqual(previousResult.latestTurn, nextLatestTurn) + ? previousResult.latestTurn + : nextLatestTurn, + pendingSourceProposedPlan, + phase, + }; + return previousResult; + }; +} + +export function createThreadConversationRuntimeSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => ThreadConversationRuntimeSnapshot | undefined { + let previousResult: ThreadConversationRuntimeSnapshot | undefined; + + return (state) => { + const resolved = resolveThreadState(state, ref); + if (!resolved) { + previousResult = undefined; + return undefined; + } + + const { session, turnState } = resolved; + const nextSession = toConversationSessionSnapshot(session); + const nextLatestTurn = toLatestTurnSnapshot(turnState?.latestTurn ?? null); + const phase = derivePhase(session); + + if ( + previousResult && + conversationSessionSnapshotsEqual(previousResult.session, nextSession) && + latestTurnSnapshotsEqual(previousResult.latestTurn, nextLatestTurn) && + previousResult.phase === phase + ) { + return previousResult; + } + + previousResult = { + session: + previousResult && conversationSessionSnapshotsEqual(previousResult.session, nextSession) + ? previousResult.session + : nextSession, + latestTurn: + previousResult && latestTurnSnapshotsEqual(previousResult.latestTurn, nextLatestTurn) + ? previousResult.latestTurn + : nextLatestTurn, + phase, + }; + return previousResult; + }; +} + +export function createThreadMessageIdsSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => readonly MessageId[] { + let previousIds: readonly MessageId[] | undefined; + let previousResult: readonly MessageId[] = EMPTY_MESSAGE_IDS; + + return (state) => { + if (!ref) { + previousIds = undefined; + previousResult = EMPTY_MESSAGE_IDS; + return previousResult; + } + + const environmentState = selectEnvironmentState(state, ref.environmentId); + const messageIds = environmentState.messageIdsByThreadId[ref.threadId]; + + if (messageIds && messageIds.length > 0) { + if ( + previousIds && + previousIds.length === messageIds.length && + previousIds.every((id, index) => id === messageIds[index]) + ) { + return previousResult; + } + previousIds = messageIds; + previousResult = messageIds; + return previousResult; + } + + previousIds = undefined; + previousResult = EMPTY_MESSAGE_IDS; + return previousResult; + }; +} + +export function createThreadActivitiesSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => Thread["activities"] { + let previousIds: string[] | undefined; + let previousActivitiesById: EnvironmentState["activityByThreadId"][ThreadId] | undefined; + let previousResult: Thread["activities"] = EMPTY_ACTIVITIES; + + return (state) => { + if (!ref) { + previousIds = undefined; + previousActivitiesById = undefined; + previousResult = EMPTY_ACTIVITIES; + return previousResult; + } + + const environmentState = selectEnvironmentState(state, ref.environmentId); + const activityIds = environmentState.activityIdsByThreadId[ref.threadId]; + const activitiesById = environmentState.activityByThreadId[ref.threadId]; + + if (previousIds === activityIds && previousActivitiesById === activitiesById) { + return previousResult; + } + + previousIds = activityIds; + previousActivitiesById = activitiesById; + const nextActivities = collectByIds( + activityIds, + activitiesById, + ) as Thread["activities"] extends Array ? Thread["activities"] : never; + previousResult = normalizeArrayResult(nextActivities, EMPTY_ACTIVITIES); + return previousResult; + }; +} + +export function createThreadPendingSnapshotSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => ThreadPendingSnapshot { + let previousIds: string[] | undefined; + let previousActivitiesById: EnvironmentState["activityByThreadId"][ThreadId] | undefined; + let previousResult: ThreadPendingSnapshot = EMPTY_THREAD_PENDING_SNAPSHOT; + + return (state) => { + if (!ref) { + previousIds = undefined; + previousActivitiesById = undefined; + previousResult = EMPTY_THREAD_PENDING_SNAPSHOT; + return previousResult; + } + + const environmentState = selectEnvironmentState(state, ref.environmentId); + const activityIds = environmentState.activityIdsByThreadId[ref.threadId]; + const activitiesById = environmentState.activityByThreadId[ref.threadId]; + + if (previousIds === activityIds && previousActivitiesById === activitiesById) { + return previousResult; + } + + previousIds = activityIds; + previousActivitiesById = activitiesById; + const activities = collectByIds( + activityIds, + activitiesById, + ) as Thread["activities"] extends infer TActivities ? TActivities : never; + const pendingApprovalRequestId = derivePendingApprovals(activities)[0]?.requestId ?? null; + const pendingUserInputRequestId = derivePendingUserInputs(activities)[0]?.requestId ?? null; + + if ( + previousResult.pendingApprovalRequestId === pendingApprovalRequestId && + previousResult.pendingUserInputRequestId === pendingUserInputRequestId + ) { + return previousResult; + } + + previousResult = { + pendingApprovalRequestId, + pendingUserInputRequestId, + }; + return previousResult; + }; +} + +export function createThreadComposerSnapshotSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => ThreadComposerSnapshot | undefined { + let previousResult: ThreadComposerSnapshot | undefined; + + return (state) => { + if (!ref) { + previousResult = undefined; + return undefined; + } + + const environmentState = selectEnvironmentState(state, ref.environmentId); + const shell = environmentState.threadShellById[ref.threadId]; + if (!shell) { + previousResult = undefined; + return undefined; + } + + const session = environmentState.threadSessionById[ref.threadId] ?? null; + const latestTurn = environmentState.threadTurnStateById[ref.threadId]?.latestTurn ?? null; + const activities = collectByIds( + environmentState.activityIdsByThreadId[ref.threadId], + environmentState.activityByThreadId[ref.threadId], + ) as Thread["activities"] extends Array ? Thread["activities"] : never; + const proposedPlans = collectByIds( + environmentState.proposedPlanIdsByThreadId[ref.threadId], + environmentState.proposedPlanByThreadId[ref.threadId], + ) as Thread["proposedPlans"] extends ProposedPlan[] ? ProposedPlan[] : never; + + const phase = derivePhase(session); + const latestTurnSettled = isLatestTurnSettled(latestTurn, session); + const pendingApprovals = derivePendingApprovals(activities); + const pendingUserInputs = derivePendingUserInputs(activities); + const activeProposedPlan = latestTurnSettled + ? findLatestProposedPlan(proposedPlans, latestTurn?.turnId ?? null) + : null; + const activePlan = deriveActivePlanState(activities, latestTurn?.turnId ?? undefined); + + const nextSession = session + ? { + provider: session.provider, + status: session.status, + activeTurnId: session.activeTurnId, + orchestrationStatus: session.orchestrationStatus, + } + : null; + const nextLatestTurn = latestTurn + ? { + turnId: latestTurn.turnId, + state: latestTurn.state, + requestedAt: latestTurn.requestedAt, + startedAt: latestTurn.startedAt, + completedAt: latestTurn.completedAt, + assistantMessageId: latestTurn.assistantMessageId, + sourceProposedPlan: latestTurn.sourceProposedPlan, + } + : null; + + if ( + previousResult && + previousResult.session?.provider === nextSession?.provider && + previousResult.session?.status === nextSession?.status && + previousResult.session?.activeTurnId === nextSession?.activeTurnId && + previousResult.session?.orchestrationStatus === nextSession?.orchestrationStatus && + previousResult.latestTurn?.turnId === nextLatestTurn?.turnId && + previousResult.latestTurn?.state === nextLatestTurn?.state && + previousResult.latestTurn?.requestedAt === nextLatestTurn?.requestedAt && + previousResult.latestTurn?.startedAt === nextLatestTurn?.startedAt && + previousResult.latestTurn?.completedAt === nextLatestTurn?.completedAt && + previousResult.latestTurn?.assistantMessageId === nextLatestTurn?.assistantMessageId && + previousResult.latestTurn?.sourceProposedPlan === nextLatestTurn?.sourceProposedPlan && + previousResult.phase === phase && + previousResult.latestTurnSettled === latestTurnSettled && + pendingApprovalsEqual(previousResult.pendingApprovals, pendingApprovals) && + pendingUserInputsEqual(previousResult.pendingUserInputs, pendingUserInputs) && + activePlanStatesEqual(previousResult.activePlan, activePlan) && + latestProposedPlansEqual(previousResult.activeProposedPlan, activeProposedPlan) + ) { + return previousResult; + } + + previousResult = { + session: + previousResult && + previousResult.session?.provider === nextSession?.provider && + previousResult.session?.status === nextSession?.status && + previousResult.session?.activeTurnId === nextSession?.activeTurnId && + previousResult.session?.orchestrationStatus === nextSession?.orchestrationStatus + ? previousResult.session + : nextSession, + latestTurn: + previousResult && + previousResult.latestTurn?.turnId === nextLatestTurn?.turnId && + previousResult.latestTurn?.state === nextLatestTurn?.state && + previousResult.latestTurn?.requestedAt === nextLatestTurn?.requestedAt && + previousResult.latestTurn?.startedAt === nextLatestTurn?.startedAt && + previousResult.latestTurn?.completedAt === nextLatestTurn?.completedAt && + previousResult.latestTurn?.assistantMessageId === nextLatestTurn?.assistantMessageId && + previousResult.latestTurn?.sourceProposedPlan === nextLatestTurn?.sourceProposedPlan + ? previousResult.latestTurn + : nextLatestTurn, + phase, + latestTurnSettled, + pendingApprovals: + previousResult && pendingApprovalsEqual(previousResult.pendingApprovals, pendingApprovals) + ? previousResult.pendingApprovals + : pendingApprovals, + pendingUserInputs: + previousResult && + pendingUserInputsEqual(previousResult.pendingUserInputs, pendingUserInputs) + ? previousResult.pendingUserInputs + : pendingUserInputs, + activeProposedPlan: + previousResult && + latestProposedPlansEqual(previousResult.activeProposedPlan, activeProposedPlan) + ? previousResult.activeProposedPlan + : activeProposedPlan, + activePlan: + previousResult && activePlanStatesEqual(previousResult.activePlan, activePlan) + ? previousResult.activePlan + : activePlan, + }; + return previousResult; + }; +} + +export function createThreadTimelineSliceSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => ThreadTimelineSliceSnapshot { + let previousResult: ThreadTimelineSliceSnapshot = EMPTY_THREAD_TIMELINE_SLICE_SNAPSHOT; + + return (state) => { + if (!ref) { + previousResult = EMPTY_THREAD_TIMELINE_SLICE_SNAPSHOT; + return previousResult; + } + + const environmentState = selectEnvironmentState(state, ref.environmentId); + const shell = environmentState.threadShellById[ref.threadId]; + if (!shell) { + previousResult = EMPTY_THREAD_TIMELINE_SLICE_SNAPSHOT; + return previousResult; + } + + const messages = collectByIds( + environmentState.messageIdsByThreadId[ref.threadId], + environmentState.messageByThreadId[ref.threadId], + ) as Thread["messages"] extends ChatMessage[] ? ChatMessage[] : never; + const proposedPlans = collectByIds( + environmentState.proposedPlanIdsByThreadId[ref.threadId], + environmentState.proposedPlanByThreadId[ref.threadId], + ) as Thread["proposedPlans"] extends ProposedPlan[] ? ProposedPlan[] : never; + const turnDiffSummaries = collectByIds( + environmentState.turnDiffIdsByThreadId[ref.threadId], + environmentState.turnDiffSummaryByThreadId[ref.threadId], + ) as Thread["turnDiffSummaries"] extends TurnDiffSummary[] ? TurnDiffSummary[] : never; + const activities = collectByIds( + environmentState.activityIdsByThreadId[ref.threadId], + environmentState.activityByThreadId[ref.threadId], + ) as Thread["activities"] extends Array ? Thread["activities"] : never; + const latestTurn = environmentState.threadTurnStateById[ref.threadId]?.latestTurn ?? null; + const activeAssistantMessageId = latestTurn?.assistantMessageId ?? null; + const activeTurnId = latestTurn?.turnId ?? undefined; + const activeAssistantMessageIndex = + activeAssistantMessageId === null + ? -1 + : messages.findIndex((message) => message.id === activeAssistantMessageId); + const activeAssistantMessageIsTail = + activeAssistantMessageIndex >= 0 && activeAssistantMessageIndex === messages.length - 1; + const shouldKeepActiveTurnContentLive = activeAssistantMessageIsTail; + + const historicalMessages = !shouldKeepActiveTurnContentLive + ? messages + : messages.filter((message) => message.id !== activeAssistantMessageId); + const liveMessages = !shouldKeepActiveTurnContentLive + ? EMPTY_MESSAGES + : messages.filter((message) => message.id === activeAssistantMessageId); + const historicalProposedPlans = + activeTurnId === undefined || !shouldKeepActiveTurnContentLive + ? proposedPlans + : proposedPlans.filter((plan) => plan.turnId !== activeTurnId); + const liveProposedPlans = + activeTurnId === undefined || !shouldKeepActiveTurnContentLive + ? EMPTY_PROPOSED_PLANS + : proposedPlans.filter((plan) => plan.turnId === activeTurnId); + const activeWorkEntries = deriveWorkLogEntries(activities, activeTurnId); + const latestTurnHasToolActivity = hasToolActivityForTurn(activities, activeTurnId); + + const nextHistoricalMessages = shallowArrayEqual( + previousResult.historicalMessages, + historicalMessages, + ) + ? previousResult.historicalMessages + : historicalMessages.length === 0 + ? EMPTY_MESSAGES + : historicalMessages; + const nextLiveMessages = shallowArrayEqual(previousResult.liveMessages, liveMessages) + ? previousResult.liveMessages + : liveMessages.length === 0 + ? EMPTY_MESSAGES + : liveMessages; + const nextHistoricalProposedPlans = shallowArrayEqual( + previousResult.historicalProposedPlans, + historicalProposedPlans, + ) + ? previousResult.historicalProposedPlans + : historicalProposedPlans.length === 0 + ? EMPTY_PROPOSED_PLANS + : historicalProposedPlans; + const nextLiveProposedPlans = shallowArrayEqual( + previousResult.liveProposedPlans, + liveProposedPlans, + ) + ? previousResult.liveProposedPlans + : liveProposedPlans.length === 0 + ? EMPTY_PROPOSED_PLANS + : liveProposedPlans; + const nextTurnDiffSummaries = shallowArrayEqual( + previousResult.turnDiffSummaries, + turnDiffSummaries, + ) + ? previousResult.turnDiffSummaries + : turnDiffSummaries.length === 0 + ? EMPTY_TURN_DIFF_SUMMARIES + : turnDiffSummaries; + const nextActiveWorkEntries = workLogEntriesEqual( + previousResult.activeWorkEntries, + activeWorkEntries, + ) + ? previousResult.activeWorkEntries + : activeWorkEntries; + + if ( + previousResult.historicalMessages === nextHistoricalMessages && + previousResult.liveMessages === nextLiveMessages && + previousResult.historicalProposedPlans === nextHistoricalProposedPlans && + previousResult.liveProposedPlans === nextLiveProposedPlans && + previousResult.turnDiffSummaries === nextTurnDiffSummaries && + previousResult.activeWorkEntries === nextActiveWorkEntries && + previousResult.latestTurnHasToolActivity === latestTurnHasToolActivity + ) { + return previousResult; + } + + previousResult = { + historicalMessages: nextHistoricalMessages, + liveMessages: nextLiveMessages, + historicalProposedPlans: nextHistoricalProposedPlans, + liveProposedPlans: nextLiveProposedPlans, + turnDiffSummaries: nextTurnDiffSummaries, + activeWorkEntries: nextActiveWorkEntries, + latestTurnHasToolActivity, + }; + return previousResult; + }; +} + +export function createThreadProposedPlansSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => Thread["proposedPlans"] { + let previousIds: string[] | undefined; + let previousProposedPlansById: EnvironmentState["proposedPlanByThreadId"][ThreadId] | undefined; + let previousResult: Thread["proposedPlans"] = EMPTY_PROPOSED_PLANS; + + return (state) => { + if (!ref) { + previousIds = undefined; + previousProposedPlansById = undefined; + previousResult = EMPTY_PROPOSED_PLANS; + return previousResult; + } + + const environmentState = selectEnvironmentState(state, ref.environmentId); + const proposedPlanIds = environmentState.proposedPlanIdsByThreadId[ref.threadId]; + const proposedPlansById = environmentState.proposedPlanByThreadId[ref.threadId]; + + if (previousIds === proposedPlanIds && previousProposedPlansById === proposedPlansById) { + return previousResult; + } + + previousIds = proposedPlanIds; + previousProposedPlansById = proposedPlansById; + const nextProposedPlans = collectByIds( + proposedPlanIds, + proposedPlansById, + ) as Thread["proposedPlans"] extends ProposedPlan[] ? ProposedPlan[] : never; + previousResult = normalizeArrayResult(nextProposedPlans, EMPTY_PROPOSED_PLANS); + return previousResult; + }; +} + +export function createThreadTurnDiffSummariesSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => Thread["turnDiffSummaries"] { + let previousIds: TurnId[] | undefined; + let previousTurnDiffsById: EnvironmentState["turnDiffSummaryByThreadId"][ThreadId] | undefined; + let previousResult: Thread["turnDiffSummaries"] = EMPTY_TURN_DIFF_SUMMARIES; + + return (state) => { + if (!ref) { + previousIds = undefined; + previousTurnDiffsById = undefined; + previousResult = EMPTY_TURN_DIFF_SUMMARIES; + return previousResult; + } + + const environmentState = selectEnvironmentState(state, ref.environmentId); + const turnDiffIds = environmentState.turnDiffIdsByThreadId[ref.threadId]; + const turnDiffsById = environmentState.turnDiffSummaryByThreadId[ref.threadId]; + + if (previousIds === turnDiffIds && previousTurnDiffsById === turnDiffsById) { + return previousResult; + } + + previousIds = turnDiffIds; + previousTurnDiffsById = turnDiffsById; + const nextTurnDiffSummaries = collectByIds( + turnDiffIds, + turnDiffsById, + ) as Thread["turnDiffSummaries"] extends TurnDiffSummary[] ? TurnDiffSummary[] : never; + previousResult = shallowArrayEqual(previousResult, nextTurnDiffSummaries) + ? previousResult + : normalizeArrayResult(nextTurnDiffSummaries, EMPTY_TURN_DIFF_SUMMARIES); + return previousResult; + }; +} + function createScopedThreadSelector( resolveRef: (state: AppState) => ScopedThreadRef | null | undefined, ): (state: AppState) => Thread | undefined { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 01b5076695..7305cbfc10 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -56,7 +56,12 @@ export default defineConfig({ tailwindcss(), ], optimizeDeps: { - include: ["@pierre/diffs", "@pierre/diffs/react", "@pierre/diffs/worker/worker.js"], + include: [ + "@pierre/diffs", + "@pierre/diffs/react", + "@pierre/diffs/worker/worker.js", + "zustand/vanilla", + ], }, define: { "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""),