From 8e5c24474044bd324ce52303faded776427d8cb3 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:30:27 +0100 Subject: [PATCH 01/48] Refactor web store into atomic slices --- apps/web/src/components/BranchToolbar.tsx | 14 +- apps/web/src/components/ChatView.browser.tsx | 18 +- apps/web/src/components/ChatView.logic.ts | 6 +- apps/web/src/components/ChatView.tsx | 59 +- apps/web/src/components/DiffPanel.tsx | 7 +- apps/web/src/components/GitActionsControl.tsx | 2 +- .../components/KeybindingsToast.browser.tsx | 18 +- apps/web/src/components/Sidebar.tsx | 16 +- .../components/settings/SettingsPanels.tsx | 19 +- apps/web/src/hooks/useHandleNewThread.ts | 8 +- apps/web/src/hooks/useThreadActions.ts | 13 +- apps/web/src/routes/__root.tsx | 13 +- apps/web/src/routes/_chat.$threadId.tsx | 2 +- apps/web/src/store.test.ts | 308 ++++-- apps/web/src/store.ts | 894 +++++++++++++----- apps/web/src/storeSelectors.ts | 158 +++- apps/web/src/types.ts | 21 + 17 files changed, 1167 insertions(+), 409 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 79c453c0f5..92929f78fc 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -34,15 +34,15 @@ export default function BranchToolbar({ onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarProps) { - const threads = useStore((store) => store.threads); - const projects = useStore((store) => store.projects); + const serverThread = useStore((store) => store.threadShellById[threadId]); + const serverSession = useStore((store) => store.threadSessionById[threadId] ?? null); const setThreadBranchAction = useStore((store) => store.setThreadBranch); const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - - const serverThread = threads.find((thread) => thread.id === threadId); const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; - const activeProject = projects.find((project) => project.id === activeProjectId); + const activeProject = useStore((store) => + activeProjectId ? store.projectById[activeProjectId] : undefined, + ); const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; @@ -60,7 +60,7 @@ export default function BranchToolbar({ const api = readNativeApi(); // If the effective cwd is about to change, stop the running session so the // next message creates a new one with the correct cwd. - if (serverThread?.session && worktreePath !== activeWorktreePath && api) { + if (serverSession && worktreePath !== activeWorktreePath && api) { void api.orchestration .dispatchCommand({ type: "thread.session.stop", @@ -96,7 +96,7 @@ export default function BranchToolbar({ }, [ activeThreadId, - serverThread?.session, + serverSession, activeWorktreePath, hasServerThread, setThreadBranchAction, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index b0263cbf40..85ef2a40a3 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1139,8 +1139,22 @@ describe("ChatView timeline estimator parity (full app)", () => { stickyActiveProvider: null, }); useStore.setState({ - projects: [], - threads: [], + projectIds: [], + projectById: {}, + threadIds: [], + threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, bootstrapComplete: false, }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ca2a671c11..6a0aa4d0c8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -3,7 +3,7 @@ import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import { Schema } from "effect"; -import { useStore } from "../store"; +import { selectThreadById, useStore } from "../store"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, @@ -202,7 +202,7 @@ export async function waitForStartedServerThread( threadId: ThreadId, timeoutMs = 1_000, ): Promise { - const getThread = () => useStore.getState().threads.find((thread) => thread.id === threadId); + const getThread = () => selectThreadById(threadId)(useStore.getState()); const thread = getThread(); if (threadHasStarted(thread)) { @@ -225,7 +225,7 @@ export async function waitForStartedServerThread( }; const unsubscribe = useStore.subscribe((state) => { - if (!threadHasStarted(state.threads.find((thread) => thread.id === threadId))) { + if (!threadHasStarted(selectThreadById(threadId)(state))) { return; } finish(true); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fd2f371005..436af61404 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -64,7 +64,7 @@ import { type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { useStore } from "../store"; -import { useProjectById, useThreadById } from "../storeSelectors"; +import { createThreadSelector } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, @@ -219,7 +219,7 @@ const threadPlanCatalogCache = new LRUCache<{ entry: ThreadPlanCatalogEntry; }>(MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES, MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES); -function estimateThreadPlanCatalogEntrySize(thread: Thread): number { +function estimateThreadPlanCatalogEntrySize(thread: Pick): number { return Math.max( 64, thread.id.length + @@ -235,7 +235,9 @@ function estimateThreadPlanCatalogEntrySize(thread: Thread): number { ); } -function toThreadPlanCatalogEntry(thread: Thread): ThreadPlanCatalogEntry { +function toThreadPlanCatalogEntry( + thread: Pick, +): ThreadPlanCatalogEntry { const cached = threadPlanCatalogCache.get(thread.id); if (cached && cached.proposedPlans === thread.proposedPlans) { return cached.entry; @@ -258,13 +260,29 @@ function toThreadPlanCatalogEntry(thread: Thread): ThreadPlanCatalogEntry { function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { const selector = useMemo(() => { - let previousThreads: Array | null = null; + let previousThreads: Array< + { id: ThreadId; proposedPlans: Thread["proposedPlans"] } | undefined + > | null = null; let previousEntries: ThreadPlanCatalogEntry[] = []; - return (state: { threads: Thread[] }): ThreadPlanCatalogEntry[] => { - const nextThreads = threadIds.map((threadId) => - state.threads.find((thread) => thread.id === threadId), - ); + return (state: { + threadShellById: Record; + proposedPlanIdsByThreadId: Record; + proposedPlanByThreadId: Record>; + }): ThreadPlanCatalogEntry[] => { + const nextThreads = threadIds.map((threadId) => { + if (!state.threadShellById[threadId]) { + return undefined; + } + return { + id: threadId, + proposedPlans: + state.proposedPlanIdsByThreadId[threadId]?.flatMap((planId) => { + const plan = state.proposedPlanByThreadId[threadId]?.[planId]; + return plan ? [plan] : []; + }) ?? [], + }; + }); const cachedThreads = previousThreads; if ( cachedThreads && @@ -426,11 +444,17 @@ function PersistentThreadTerminalDrawer({ closeShortcutLabel, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { - const serverThread = useThreadById(threadId); + const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId])); const draftThread = useComposerDraftStore( (store) => store.draftThreadsByThreadId[threadId] ?? null, ); - const project = useProjectById(serverThread?.projectId ?? draftThread?.projectId); + const project = useStore((state) => + serverThread?.projectId + ? state.projectById[serverThread.projectId] + : draftThread?.projectId + ? state.projectById[draftThread.projectId] + : undefined, + ); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadId, threadId), ); @@ -565,7 +589,7 @@ function PersistentThreadTerminalDrawer({ } export default function ChatView({ threadId }: ChatViewProps) { - const serverThread = useThreadById(threadId); + const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId])); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); @@ -742,8 +766,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); - const threads = useStore((state) => state.threads); - const serverThreadIds = useMemo(() => threads.map((thread) => thread.id), [threads]); + const serverThreadIds = useStore((state) => state.threadIds); const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); const draftThreadIds = useMemo( () => Object.keys(draftThreadsByThreadId) as ThreadId[], @@ -804,7 +827,9 @@ export default function ChatView({ threadId }: ChatViewProps) { [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], ); - const fallbackDraftProject = useProjectById(draftThread?.projectId); + const fallbackDraftProject = useStore((state) => + draftThread?.projectId ? state.projectById[draftThread.projectId] : undefined, + ); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => @@ -869,7 +894,9 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProject = useProjectById(activeThread?.projectId); + const activeProject = useStore((state) => + activeThread?.projectId ? state.projectById[activeThread.projectId] : undefined, + ); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -1595,7 +1622,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; - if (useStore.getState().threads.some((thread) => thread.id === targetThreadId)) { + if (useStore.getState().threadShellById[targetThreadId] !== undefined) { setStoreThreadError(targetThreadId, error); return; } diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index dc376a5b3d..0780035e04 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -30,6 +30,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useStore } from "../store"; +import { createThreadSelector } from "../storeSelectors"; import { useSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; @@ -181,12 +182,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); const diffOpen = diffSearch.diff === "1"; const activeThreadId = routeThreadId; - const activeThread = useStore((store) => - activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, + const activeThread = useStore( + useMemo(() => createThreadSelector(activeThreadId), [activeThreadId]), ); const activeProjectId = activeThread?.projectId ?? null; const activeProject = useStore((store) => - activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, + activeProjectId ? store.projectById[activeProjectId] : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null)); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 42882d000d..85199cf201 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -211,7 +211,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [activeThreadId], ); const activeServerThread = useStore((store) => - activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, + activeThreadId ? store.threadShellById[activeThreadId] : undefined, ); const setThreadBranch = useStore((store) => store.setThreadBranch); const queryClient = useQueryClient(); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index b8398e7b8f..68e8935106 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -323,8 +323,22 @@ describe("Keybindings update toast", () => { projectDraftThreadIdByProjectId: {}, }); useStore.setState({ - projects: [], - threads: [], + projectIds: [], + projectById: {}, + threadIds: [], + threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, bootstrapComplete: false, }); }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 89ced46454..68e53cdb3a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -129,7 +129,6 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import { useSidebarThreadSummaryById } from "../storeSelectors"; import type { Project } from "../types"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { @@ -281,7 +280,7 @@ interface SidebarThreadRowProps { } function SidebarThreadRow(props: SidebarThreadRowProps) { - const thread = useSidebarThreadSummaryById(props.threadId); + const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); const runningTerminalIds = useTerminalStateStore( (state) => @@ -666,8 +665,9 @@ function SortableProjectItem({ } export default function Sidebar() { - const projects = useStore((store) => store.projects); - const sidebarThreadsById = useStore((store) => store.sidebarThreadsById); + const projectIds = useStore((store) => store.projectIds); + const projectById = useStore((store) => store.projectById); + const sidebarThreadsById = useStore((store) => store.sidebarThreadSummaryById); const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( useShallow((store) => ({ @@ -729,6 +729,14 @@ export default function Sidebar() { const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const projects = useMemo( + () => + projectIds.flatMap((projectId) => { + const project = projectById[projectId]; + return project ? [project] : []; + }), + [projectById, projectIds], + ); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e3e620030b..4c258b62e8 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1414,12 +1414,21 @@ export function GeneralSettingsPanel() { } export function ArchivedThreadsPanel() { - const projects = useStore((store) => store.projects); - const threads = useStore((store) => store.threads); + const projectIds = useStore((store) => store.projectIds); + const projectById = useStore((store) => store.projectById); + const threadIds = useStore((store) => store.threadIds); + const threadShellById = useStore((store) => store.threadShellById); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); const archivedGroups = useMemo(() => { - const projectById = new Map(projects.map((project) => [project.id, project] as const)); - return [...projectById.values()] + const projects = projectIds.flatMap((projectId) => { + const project = projectById[projectId]; + return project ? [project] : []; + }); + const threads = threadIds.flatMap((threadId) => { + const thread = threadShellById[threadId]; + return thread ? [thread] : []; + }); + return projects .map((project) => ({ project, threads: threads @@ -1431,7 +1440,7 @@ export function ArchivedThreadsPanel() { }), })) .filter((group) => group.threads.length > 0); - }, [projects, threads]); + }, [projectById, projectIds, threadIds, threadShellById]); const handleArchivedThreadContextMenu = useCallback( async (threadId: ThreadId, position: { x: number; y: number }) => { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 1547035bf4..f08b2c7a57 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -10,18 +10,20 @@ import { import { newThreadId } from "../lib/utils"; import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; import { useStore } from "../store"; -import { useThreadById } from "../storeSelectors"; +import { createThreadSelector } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; export function useHandleNewThread() { - const projectIds = useStore(useShallow((store) => store.projects.map((project) => project.id))); + const projectIds = useStore(useShallow((store) => store.projectIds)); const projectOrder = useUiStateStore((store) => store.projectOrder); const navigate = useNavigate(); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); - const activeThread = useThreadById(routeThreadId); + const activeThread = useStore( + useMemo(() => createThreadSelector(routeThreadId), [routeThreadId]), + ); const activeDraftThread = useComposerDraftStore((store) => routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, ); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index d5557b4a96..bc13b872cd 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -9,7 +9,7 @@ import { useHandleNewThread } from "./useHandleNewThread"; import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; import { newCommandId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; -import { useStore } from "../store"; +import { selectProjectById, selectThreadById, selectThreads, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { toastManager } from "../components/ui/toast"; @@ -35,7 +35,7 @@ export function useThreadActions() { async (threadId: ThreadId) => { const api = readNativeApi(); if (!api) return; - const thread = useStore.getState().threads.find((entry) => entry.id === threadId); + const thread = selectThreadById(threadId)(useStore.getState()); if (!thread) return; if (thread.session?.status === "running" && thread.session.activeTurnId != null) { throw new Error("Cannot archive a running thread."); @@ -68,10 +68,11 @@ export function useThreadActions() { async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet } = {}) => { const api = readNativeApi(); if (!api) return; - const { projects, threads } = useStore.getState(); - const thread = threads.find((entry) => entry.id === threadId); + const state = useStore.getState(); + const threads = selectThreads(state); + const thread = selectThreadById(threadId)(state); if (!thread) return; - const threadProject = projects.find((project) => project.id === thread.projectId); + const threadProject = selectProjectById(thread.projectId)(state); const deletedIds = opts.deletedThreadIds; const survivingThreads = deletedIds && deletedIds.size > 0 @@ -179,7 +180,7 @@ export function useThreadActions() { async (threadId: ThreadId) => { const api = readNativeApi(); if (!api) return; - const thread = useStore.getState().threads.find((entry) => entry.id === threadId); + const thread = selectThreadById(threadId)(useStore.getState()); if (!thread) return; if (appSettings.confirmThreadDelete) { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 3377e4bb44..04bfd61308 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -33,7 +33,7 @@ import { clearPromotedDraftThreads, useComposerDraftStore, } from "../composerDraftStore"; -import { useStore } from "../store"; +import { selectProjects, selectThreadById, selectThreads, useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; import { useTerminalStateStore } from "../terminalStateStore"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; @@ -314,8 +314,9 @@ function EventRouter() { let flushPendingDomainEventsScheduled = false; const reconcileSnapshotDerivedState = () => { - const threads = useStore.getState().threads; - const projects = useStore.getState().projects; + const storeState = useStore.getState(); + const threads = selectThreads(storeState); + const projects = selectProjects(storeState); syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); syncThreads( threads.map((thread) => ({ @@ -378,14 +379,14 @@ function EventRouter() { applyOrchestrationEvents(uiEvents); if (needsProjectUiSync) { - const projects = useStore.getState().projects; + const projects = selectProjects(useStore.getState()); syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); } const needsThreadUiSync = nextEvents.some( (event) => event.type === "thread.created" || event.type === "thread.deleted", ); if (needsThreadUiSync) { - const threads = useStore.getState().threads; + const threads = selectThreads(useStore.getState()); syncThreads( threads.map((thread) => ({ id: thread.id, @@ -501,7 +502,7 @@ function EventRouter() { } }); const unsubTerminalEvent = api.terminal.onEvent((event) => { - const thread = useStore.getState().threads.find((entry) => entry.id === event.threadId); + const thread = selectThreadById(ThreadId.makeUnsafe(event.threadId))(useStore.getState()); if (thread && thread.archivedAt !== null) { return; } diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 31920cf40f..99ecc05e7d 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -167,7 +167,7 @@ function ChatThreadRouteView() { select: (params) => ThreadId.makeUnsafe(params.threadId), }); const search = Route.useSearch(); - const threadExists = useStore((store) => store.threads.some((thread) => thread.id === threadId)); + const threadExists = useStore((store) => store.threadShellById[threadId] !== undefined); const draftThreadExists = useComposerDraftStore((store) => Object.hasOwn(store.draftThreadsByThreadId, threadId), ); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index da1498d494..2294674848 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -14,6 +14,8 @@ import { describe, expect, it } from "vitest"; import { applyOrchestrationEvent, applyOrchestrationEvents, + selectProjects, + selectThreads, syncServerReadModel, type AppState, } from "./store"; @@ -47,29 +49,125 @@ function makeThread(overrides: Partial = {}): Thread { } function makeState(thread: Thread): AppState { + const projectId = ProjectId.makeUnsafe("project-1"); + const project = { + id: projectId, + name: "Project", + cwd: "/tmp/project", + defaultModelSelection: { + provider: "codex" as const, + model: "gpt-5-codex", + }, + createdAt: "2026-02-13T00:00:00.000Z", + updatedAt: "2026-02-13T00:00:00.000Z", + scripts: [], + }; const threadIdsByProjectId: AppState["threadIdsByProjectId"] = { [thread.projectId]: [thread.id], }; return { - projects: [ - { - id: ProjectId.makeUnsafe("project-1"), - name: "Project", - cwd: "/tmp/project", - defaultModelSelection: { - provider: "codex", - model: "gpt-5-codex", - }, - scripts: [], - }, - ], - threads: [thread], - sidebarThreadsById: {}, + projectIds: [projectId], + projectById: { + [projectId]: project, + }, + threadIds: [thread.id], threadIdsByProjectId, + threadShellById: { + [thread.id]: { + id: thread.id, + codexThreadId: thread.codexThreadId, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + error: thread.error, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + }, + threadSessionById: { + [thread.id]: thread.session, + }, + threadTurnStateById: { + [thread.id]: { + latestTurn: thread.latestTurn, + ...(thread.pendingSourceProposedPlan + ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } + : {}), + }, + }, + messageIdsByThreadId: { + [thread.id]: thread.messages.map((message) => message.id), + }, + messageByThreadId: { + [thread.id]: Object.fromEntries( + thread.messages.map((message) => [message.id, message] as const), + ) as AppState["messageByThreadId"][ThreadId], + }, + activityIdsByThreadId: { + [thread.id]: thread.activities.map((activity) => activity.id), + }, + activityByThreadId: { + [thread.id]: Object.fromEntries( + thread.activities.map((activity) => [activity.id, activity] as const), + ) as AppState["activityByThreadId"][ThreadId], + }, + proposedPlanIdsByThreadId: { + [thread.id]: thread.proposedPlans.map((plan) => plan.id), + }, + proposedPlanByThreadId: { + [thread.id]: Object.fromEntries( + thread.proposedPlans.map((plan) => [plan.id, plan] as const), + ) as AppState["proposedPlanByThreadId"][ThreadId], + }, + turnDiffIdsByThreadId: { + [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId), + }, + turnDiffSummaryByThreadId: { + [thread.id]: Object.fromEntries( + thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), + ) as AppState["turnDiffSummaryByThreadId"][ThreadId], + }, + sidebarThreadSummaryById: {}, + bootstrapComplete: true, + }; +} + +function makeEmptyState(overrides: Partial = {}): AppState { + return { + projectIds: [], + projectById: {}, + threadIds: [], + threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, bootstrapComplete: true, + ...overrides, }; } +function projectsOf(state: AppState) { + return selectProjects(state); +} + +function threadsOf(state: AppState) { + return selectThreads(state); +} + function makeEvent( type: T, payload: Extract["payload"], @@ -191,7 +289,7 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.threads[0]?.modelSelection.model).toBe("claude-opus-4-6"); + expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6"); }); it("resolves claude aliases when session provider is claudeAgent", () => { @@ -216,7 +314,7 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.threads[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); + expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); }); it("preserves project and thread updatedAt timestamps from the read model", () => { @@ -229,8 +327,8 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.projects[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); - expect(next.threads[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); + expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); + expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); }); it("maps archivedAt from the read model", () => { @@ -245,16 +343,17 @@ describe("store read model sync", () => { ), ); - expect(next.threads[0]?.archivedAt).toBe(archivedAt); + expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt); }); it("replaces projects using snapshot order during recovery", () => { const project1 = ProjectId.makeUnsafe("project-1"); const project2 = ProjectId.makeUnsafe("project-2"); const project3 = ProjectId.makeUnsafe("project-3"); - const initialState: AppState = { - projects: [ - { + const initialState: AppState = makeEmptyState({ + projectIds: [project2, project1], + projectById: { + [project2]: { id: project2, name: "Project 2", cwd: "/tmp/project-2", @@ -262,9 +361,11 @@ describe("store read model sync", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], }, - { + [project1]: { id: project1, name: "Project 1", cwd: "/tmp/project-1", @@ -272,14 +373,12 @@ describe("store read model sync", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], }, - ], - threads: [], - sidebarThreadsById: {}, - threadIdsByProjectId: {}, - bootstrapComplete: true, - }; + }, + }); const readModel: OrchestrationReadModel = { snapshotSequence: 2, updatedAt: "2026-02-27T00:00:00.000Z", @@ -305,7 +404,7 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.projects.map((project) => project.id)).toEqual([project1, project2, project3]); + expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); }); }); @@ -354,9 +453,10 @@ describe("incremental orchestration updates", () => { it("reuses an existing project row when project.created arrives with a new id for the same cwd", () => { const originalProjectId = ProjectId.makeUnsafe("project-1"); const recreatedProjectId = ProjectId.makeUnsafe("project-2"); - const state: AppState = { - projects: [ - { + const state: AppState = makeEmptyState({ + projectIds: [originalProjectId], + projectById: { + [originalProjectId]: { id: originalProjectId, name: "Project", cwd: "/tmp/project", @@ -364,14 +464,12 @@ describe("incremental orchestration updates", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], }, - ], - threads: [], - sidebarThreadsById: {}, - threadIdsByProjectId: {}, - bootstrapComplete: true, - }; + }, + }); const next = applyOrchestrationEvent( state, @@ -389,10 +487,10 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.projects).toHaveLength(1); - expect(next.projects[0]?.id).toBe(recreatedProjectId); - expect(next.projects[0]?.cwd).toBe("/tmp/project"); - expect(next.projects[0]?.name).toBe("Project Recreated"); + expect(projectsOf(next)).toHaveLength(1); + expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId); + expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project"); + expect(projectsOf(next)[0]?.name).toBe("Project Recreated"); }); it("removes stale project index entries when thread.created recreates a thread under a new project", () => { @@ -404,8 +502,10 @@ describe("incremental orchestration updates", () => { projectId: originalProjectId, }); const state: AppState = { - projects: [ - { + ...makeState(thread), + projectIds: [originalProjectId, recreatedProjectId], + projectById: { + [originalProjectId]: { id: originalProjectId, name: "Project 1", cwd: "/tmp/project-1", @@ -413,9 +513,11 @@ describe("incremental orchestration updates", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], }, - { + [recreatedProjectId]: { id: recreatedProjectId, name: "Project 2", cwd: "/tmp/project-2", @@ -423,15 +525,11 @@ describe("incremental orchestration updates", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], }, - ], - threads: [thread], - sidebarThreadsById: {}, - threadIdsByProjectId: { - [originalProjectId]: [threadId], }, - bootstrapComplete: true, }; const next = applyOrchestrationEvent( @@ -453,8 +551,8 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads).toHaveLength(1); - expect(next.threads[0]?.projectId).toBe(recreatedProjectId); + expect(threadsOf(next)).toHaveLength(1); + expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId); expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined(); expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]); }); @@ -477,7 +575,73 @@ describe("incremental orchestration updates", () => { const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") }); const state: AppState = { ...makeState(thread1), - threads: [thread1, thread2], + threadIds: [thread1.id, thread2.id], + threadShellById: { + ...makeState(thread1).threadShellById, + [thread2.id]: { + id: thread2.id, + codexThreadId: thread2.codexThreadId, + projectId: thread2.projectId, + title: thread2.title, + modelSelection: thread2.modelSelection, + runtimeMode: thread2.runtimeMode, + interactionMode: thread2.interactionMode, + error: thread2.error, + createdAt: thread2.createdAt, + archivedAt: thread2.archivedAt, + updatedAt: thread2.updatedAt, + branch: thread2.branch, + worktreePath: thread2.worktreePath, + }, + }, + threadSessionById: { + ...makeState(thread1).threadSessionById, + [thread2.id]: thread2.session, + }, + threadTurnStateById: { + ...makeState(thread1).threadTurnStateById, + [thread2.id]: { + latestTurn: thread2.latestTurn, + }, + }, + messageIdsByThreadId: { + ...makeState(thread1).messageIdsByThreadId, + [thread2.id]: [], + }, + messageByThreadId: { + ...makeState(thread1).messageByThreadId, + [thread2.id]: {}, + }, + activityIdsByThreadId: { + ...makeState(thread1).activityIdsByThreadId, + [thread2.id]: [], + }, + activityByThreadId: { + ...makeState(thread1).activityByThreadId, + [thread2.id]: {}, + }, + proposedPlanIdsByThreadId: { + ...makeState(thread1).proposedPlanIdsByThreadId, + [thread2.id]: [], + }, + proposedPlanByThreadId: { + ...makeState(thread1).proposedPlanByThreadId, + [thread2.id]: {}, + }, + turnDiffIdsByThreadId: { + ...makeState(thread1).turnDiffIdsByThreadId, + [thread2.id]: [], + }, + turnDiffSummaryByThreadId: { + ...makeState(thread1).turnDiffSummaryByThreadId, + [thread2.id]: {}, + }, + sidebarThreadSummaryById: { + ...makeState(thread1).sidebarThreadSummaryById, + }, + threadIdsByProjectId: { + [thread1.projectId]: [thread1.id, thread2.id], + }, }; const next = applyOrchestrationEvent( @@ -494,9 +658,9 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads[0]?.messages[0]?.text).toBe("hello world"); - expect(next.threads[0]?.latestTurn?.state).toBe("running"); - expect(next.threads[1]).toBe(thread2); + expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); + expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); + expect(threadsOf(next)[1]).toBe(thread2); }); it("applies replay batches in sequence and updates session state", () => { @@ -545,9 +709,9 @@ describe("incremental orchestration updates", () => { ), ]); - expect(next.threads[0]?.session?.status).toBe("running"); - expect(next.threads[0]?.latestTurn?.state).toBe("completed"); - expect(next.threads[0]?.messages).toHaveLength(1); + expect(threadsOf(next)[0]?.session?.status).toBe("running"); + expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed"); + expect(threadsOf(next)[0]?.messages).toHaveLength(1); }); it("does not regress latestTurn when an older turn diff completes late", () => { @@ -578,8 +742,8 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads[0]?.turnDiffSummaries).toHaveLength(1); - expect(next.threads[0]?.latestTurn).toEqual(state.threads[0]?.latestTurn); + expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1); + expect(threadsOf(next)[0]?.latestTurn).toEqual(threadsOf(state)[0]?.latestTurn); }); it("rebinds live turn diffs to the authoritative assistant message when it arrives later", () => { @@ -622,10 +786,10 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( + expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( MessageId.makeUnsafe("assistant-real"), ); - expect(next.threads[0]?.latestTurn?.assistantMessageId).toBe( + expect(threadsOf(next)[0]?.latestTurn?.assistantMessageId).toBe( MessageId.makeUnsafe("assistant-real"), ); }); @@ -731,15 +895,15 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads[0]?.messages.map((message) => message.id)).toEqual([ + expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([ "user-1", "assistant-1", ]); - expect(next.threads[0]?.proposedPlans.map((plan) => plan.id)).toEqual(["plan-1"]); - expect(next.threads[0]?.activities.map((activity) => activity.id)).toEqual([ + expect(threadsOf(next)[0]?.proposedPlans.map((plan) => plan.id)).toEqual(["plan-1"]); + expect(threadsOf(next)[0]?.activities.map((activity) => activity.id)).toEqual([ EventId.makeUnsafe("activity-1"), ]); - expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ + expect(threadsOf(next)[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ TurnId.makeUnsafe("turn-1"), ]); }); @@ -789,7 +953,7 @@ describe("incremental orchestration updates", () => { }), ); - expect(reverted.threads[0]?.pendingSourceProposedPlan).toBeUndefined(); + expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined(); const next = applyOrchestrationEvent( reverted, @@ -807,10 +971,10 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads[0]?.latestTurn).toMatchObject({ + expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ turnId: TurnId.makeUnsafe("turn-3"), state: "running", }); - expect(next.threads[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); + expect(threadsOf(next)[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); }); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 12c709b796..318d92173c 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,83 +1,100 @@ import { + type MessageId, + type OrchestrationCheckpointSummary, type OrchestrationEvent, type OrchestrationMessage, type OrchestrationProposedPlan, - type ProjectId, - type ProviderKind, - ThreadId, type OrchestrationReadModel, type OrchestrationSession, - type OrchestrationCheckpointSummary, - type OrchestrationThread, type OrchestrationSessionStatus, + type OrchestrationThread, + type OrchestrationThreadActivity, + type ProjectId, + type ProviderKind, + ThreadId, + type TurnId, } from "@t3tools/contracts"; import { resolveModelSlugForProvider } from "@t3tools/shared/model"; import { create } from "zustand"; import { - findLatestProposedPlan, - hasActionableProposedPlan, derivePendingApprovals, derivePendingUserInputs, + findLatestProposedPlan, + hasActionableProposedPlan, } from "./session-logic"; -import { type ChatMessage, type Project, type SidebarThreadSummary, type Thread } from "./types"; - -// ── State ──────────────────────────────────────────────────────────── +import { + type ChatMessage, + type Project, + type ProposedPlan, + type SidebarThreadSummary, + type Thread, + type ThreadSession, + type ThreadShell, + type ThreadTurnState, + type TurnDiffSummary, +} from "./types"; export interface AppState { - projects: Project[]; - threads: Thread[]; - sidebarThreadsById: Record; - threadIdsByProjectId: Record; + projectIds: ProjectId[]; + projectById: Record; + threadIds: ThreadId[]; + threadIdsByProjectId: Record; + threadShellById: Record; + threadSessionById: Record; + threadTurnStateById: Record; + messageIdsByThreadId: Record; + messageByThreadId: Record>; + activityIdsByThreadId: Record; + activityByThreadId: Record>; + proposedPlanIdsByThreadId: Record; + proposedPlanByThreadId: Record>; + turnDiffIdsByThreadId: Record; + turnDiffSummaryByThreadId: Record>; + sidebarThreadSummaryById: Record; bootstrapComplete: boolean; } const initialState: AppState = { - projects: [], - threads: [], - sidebarThreadsById: {}, + projectIds: [], + projectById: {}, + threadIds: [], threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, bootstrapComplete: false, }; + const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; const MAX_THREAD_PROPOSED_PLANS = 200; const MAX_THREAD_ACTIVITIES = 500; const EMPTY_THREAD_IDS: ThreadId[] = []; - -// ── Pure helpers ────────────────────────────────────────────────────── - -function updateThread( - threads: Thread[], - threadId: ThreadId, - updater: (t: Thread) => Thread, -): Thread[] { - let changed = false; - const next = threads.map((t) => { - if (t.id !== threadId) return t; - const updated = updater(t); - if (updated !== t) changed = true; - return updated; - }); - return changed ? next : threads; -} - -function updateProject( - projects: Project[], - projectId: Project["id"], - updater: (project: Project) => Project, -): Project[] { - let changed = false; - const next = projects.map((project) => { - if (project.id !== projectId) { - return project; - } - const updated = updater(project); - if (updated !== project) { - changed = true; - } - return updated; - }); - return changed ? next : projects; +const EMPTY_MESSAGE_IDS: MessageId[] = []; +const EMPTY_ACTIVITY_IDS: string[] = []; +const EMPTY_PROPOSED_PLAN_IDS: string[] = []; +const EMPTY_TURN_IDS: TurnId[] = []; +const EMPTY_MESSAGES: ChatMessage[] = []; +const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; +const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; +const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; +const EMPTY_MESSAGE_MAP: Record = {}; +const EMPTY_ACTIVITY_MAP: Record = {}; +const EMPTY_PROPOSED_PLAN_MAP: Record = {}; +const EMPTY_TURN_DIFF_MAP: Record = {}; +const EMPTY_THREAD_TURN_STATE: ThreadTurnState = Object.freeze({ latestTurn: null }); + +function arraysEqual(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); } function normalizeModelSelection( @@ -93,7 +110,7 @@ function mapProjectScripts(scripts: ReadonlyArray): return scripts.map((script) => ({ ...script })); } -function mapSession(session: OrchestrationSession): Thread["session"] { +function mapSession(session: OrchestrationSession): ThreadSession { return { provider: toLegacyProvider(session.providerName), status: toLegacySessionStatus(session.status), @@ -127,7 +144,7 @@ function mapMessage(message: OrchestrationMessage): ChatMessage { }; } -function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): Thread["proposedPlans"][number] { +function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): ProposedPlan { return { id: proposedPlan.id, turnId: proposedPlan.turnId, @@ -139,9 +156,7 @@ function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): Thread["propo }; } -function mapTurnDiffSummary( - checkpoint: OrchestrationCheckpointSummary, -): Thread["turnDiffSummaries"][number] { +function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDiffSummary { return { turnId: checkpoint.turnId, completedAt: checkpoint.completedAt, @@ -153,6 +168,20 @@ function mapTurnDiffSummary( }; } +function mapProject(project: OrchestrationReadModel["projects"][number]): Project { + return { + id: project.id, + name: project.title, + cwd: project.workspaceRoot, + defaultModelSelection: project.defaultModelSelection + ? normalizeModelSelection(project.defaultModelSelection) + : null, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + scripts: mapProjectScripts(project.scripts), + }; +} + function mapThread(thread: OrchestrationThread): Thread { return { id: thread.id, @@ -178,25 +207,35 @@ function mapThread(thread: OrchestrationThread): Thread { }; } -function mapProject(project: OrchestrationReadModel["projects"][number]): Project { +function toThreadShell(thread: Thread): ThreadShell { return { - id: project.id, - name: project.title, - cwd: project.workspaceRoot, - defaultModelSelection: project.defaultModelSelection - ? normalizeModelSelection(project.defaultModelSelection) - : null, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - scripts: mapProjectScripts(project.scripts), + id: thread.id, + codexThreadId: thread.codexThreadId, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + error: thread.error, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, }; } -function getLatestUserMessageAt( - messages: ReadonlyArray, -): string | null { - let latestUserMessageAt: string | null = null; +function toThreadTurnState(thread: Thread): ThreadTurnState { + return { + latestTurn: thread.latestTurn, + ...(thread.pendingSourceProposedPlan + ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } + : {}), + }; +} +function getLatestUserMessageAt(messages: ReadonlyArray): string | null { + let latestUserMessageAt: string | null = null; for (const message of messages) { if (message.role !== "user") { continue; @@ -205,7 +244,6 @@ function getLatestUserMessageAt( latestUserMessageAt = message.createdAt; } } - return latestUserMessageAt; } @@ -255,62 +293,378 @@ function sidebarThreadSummariesEqual( ); } -function appendThreadIdByProjectId( - threadIdsByProjectId: Record, - projectId: ProjectId, - threadId: ThreadId, -): Record { - const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS; - if (existingThreadIds.includes(threadId)) { - return threadIdsByProjectId; - } +function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): boolean { + return ( + left !== undefined && + left.id === right.id && + left.codexThreadId === right.codexThreadId && + left.projectId === right.projectId && + left.title === right.title && + left.modelSelection === right.modelSelection && + left.runtimeMode === right.runtimeMode && + left.interactionMode === right.interactionMode && + left.error === right.error && + left.createdAt === right.createdAt && + left.archivedAt === right.archivedAt && + left.updatedAt === right.updatedAt && + left.branch === right.branch && + left.worktreePath === right.worktreePath + ); +} + +function threadTurnStatesEqual(left: ThreadTurnState | undefined, right: ThreadTurnState): boolean { + return ( + left !== undefined && + left.latestTurn === right.latestTurn && + left.pendingSourceProposedPlan === right.pendingSourceProposedPlan + ); +} + +function appendId(ids: readonly T[], id: T): T[] { + return ids.includes(id) ? [...ids] : [...ids, id]; +} + +function removeId(ids: readonly T[], id: T): T[] { + return ids.filter((value) => value !== id); +} + +function buildMessageSlice(thread: Thread): { + ids: MessageId[]; + byId: Record; +} { + return { + ids: thread.messages.map((message) => message.id), + byId: Object.fromEntries( + thread.messages.map((message) => [message.id, message] as const), + ) as Record, + }; +} + +function buildActivitySlice(thread: Thread): { + ids: string[]; + byId: Record; +} { return { - ...threadIdsByProjectId, - [projectId]: [...existingThreadIds, threadId], + ids: thread.activities.map((activity) => activity.id), + byId: Object.fromEntries( + thread.activities.map((activity) => [activity.id, activity] as const), + ) as Record, }; } -function removeThreadIdByProjectId( - threadIdsByProjectId: Record, - projectId: ProjectId, +function buildProposedPlanSlice(thread: Thread): { + ids: string[]; + byId: Record; +} { + return { + ids: thread.proposedPlans.map((plan) => plan.id), + byId: Object.fromEntries( + thread.proposedPlans.map((plan) => [plan.id, plan] as const), + ) as Record, + }; +} + +function buildTurnDiffSlice(thread: Thread): { + ids: TurnId[]; + byId: Record; +} { + return { + ids: thread.turnDiffSummaries.map((summary) => summary.turnId), + byId: Object.fromEntries( + thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), + ) as Record, + }; +} + +function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[] { + const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS; + const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP; + if (ids.length === 0) { + return EMPTY_MESSAGES; + } + return ids.flatMap((id) => { + const message = byId[id]; + return message ? [message] : []; + }); +} + +function selectThreadActivities( + state: AppState, threadId: ThreadId, -): Record { - const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS; - if (!existingThreadIds.includes(threadId)) { - return threadIdsByProjectId; +): OrchestrationThreadActivity[] { + const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS; + const byId = state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP; + if (ids.length === 0) { + return EMPTY_ACTIVITIES; } - const nextThreadIds = existingThreadIds.filter( - (existingThreadId) => existingThreadId !== threadId, - ); - if (nextThreadIds.length === existingThreadIds.length) { - return threadIdsByProjectId; + return ids.flatMap((id) => { + const activity = byId[id]; + return activity ? [activity] : []; + }); +} + +function selectThreadProposedPlans(state: AppState, threadId: ThreadId): ProposedPlan[] { + const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS; + const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP; + if (ids.length === 0) { + return EMPTY_PROPOSED_PLANS; } - if (nextThreadIds.length === 0) { - const nextThreadIdsByProjectId = { ...threadIdsByProjectId }; - delete nextThreadIdsByProjectId[projectId]; - return nextThreadIdsByProjectId; + return ids.flatMap((id) => { + const plan = byId[id]; + return plan ? [plan] : []; + }); +} + +function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): TurnDiffSummary[] { + const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS; + const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP; + if (ids.length === 0) { + return EMPTY_TURN_DIFF_SUMMARIES; + } + return ids.flatMap((id) => { + const summary = byId[id]; + return summary ? [summary] : []; + }); +} + +function getThread(state: AppState, threadId: ThreadId): Thread | undefined { + const shell = state.threadShellById[threadId]; + if (!shell) { + return undefined; } + const turnState = state.threadTurnStateById[threadId] ?? EMPTY_THREAD_TURN_STATE; return { - ...threadIdsByProjectId, - [projectId]: nextThreadIds, + ...shell, + session: state.threadSessionById[threadId] ?? null, + latestTurn: turnState.latestTurn, + pendingSourceProposedPlan: turnState.pendingSourceProposedPlan, + messages: selectThreadMessages(state, threadId), + activities: selectThreadActivities(state, threadId), + proposedPlans: selectThreadProposedPlans(state, threadId), + turnDiffSummaries: selectThreadTurnDiffSummaries(state, threadId), }; } -function buildThreadIdsByProjectId(threads: ReadonlyArray): Record { - const threadIdsByProjectId: Record = {}; - for (const thread of threads) { - const existingThreadIds = threadIdsByProjectId[thread.projectId] ?? EMPTY_THREAD_IDS; - threadIdsByProjectId[thread.projectId] = [...existingThreadIds, thread.id]; +function getProjects(state: AppState): Project[] { + return state.projectIds.flatMap((projectId) => { + const project = state.projectById[projectId]; + return project ? [project] : []; + }); +} + +function getThreads(state: AppState): Thread[] { + return state.threadIds.flatMap((threadId) => { + const thread = getThread(state, threadId); + return thread ? [thread] : []; + }); +} + +function writeThreadState(state: AppState, nextThread: Thread, previousThread?: Thread): AppState { + const nextShell = toThreadShell(nextThread); + const nextTurnState = toThreadTurnState(nextThread); + const previousShell = state.threadShellById[nextThread.id]; + const previousTurnState = state.threadTurnStateById[nextThread.id]; + const previousSummary = state.sidebarThreadSummaryById[nextThread.id]; + const nextSummary = buildSidebarThreadSummary(nextThread); + + let nextState = state; + + if (!state.threadIds.includes(nextThread.id)) { + nextState = { + ...nextState, + threadIds: [...nextState.threadIds, nextThread.id], + }; } - return threadIdsByProjectId; + + const previousProjectId = previousThread?.projectId; + const nextProjectId = nextThread.projectId; + if (previousProjectId !== nextProjectId) { + let threadIdsByProjectId = nextState.threadIdsByProjectId; + if (previousProjectId) { + const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS; + const nextIds = removeId(previousIds, nextThread.id); + if (nextIds.length === 0) { + const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId; + threadIdsByProjectId = rest as Record; + } else if (!arraysEqual(previousIds, nextIds)) { + threadIdsByProjectId = { + ...threadIdsByProjectId, + [previousProjectId]: nextIds, + }; + } + } + const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS; + const nextProjectThreadIds = appendId(projectThreadIds, nextThread.id); + if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) { + threadIdsByProjectId = { + ...threadIdsByProjectId, + [nextProjectId]: nextProjectThreadIds, + }; + } + if (threadIdsByProjectId !== nextState.threadIdsByProjectId) { + nextState = { + ...nextState, + threadIdsByProjectId, + }; + } + } + + if (!threadShellsEqual(previousShell, nextShell)) { + nextState = { + ...nextState, + threadShellById: { + ...nextState.threadShellById, + [nextThread.id]: nextShell, + }, + }; + } + + if ((previousThread?.session ?? null) !== nextThread.session) { + nextState = { + ...nextState, + threadSessionById: { + ...nextState.threadSessionById, + [nextThread.id]: nextThread.session, + }, + }; + } + + if (!threadTurnStatesEqual(previousTurnState, nextTurnState)) { + nextState = { + ...nextState, + threadTurnStateById: { + ...nextState.threadTurnStateById, + [nextThread.id]: nextTurnState, + }, + }; + } + + if (previousThread?.messages !== nextThread.messages) { + const nextMessageSlice = buildMessageSlice(nextThread); + nextState = { + ...nextState, + messageIdsByThreadId: { + ...nextState.messageIdsByThreadId, + [nextThread.id]: nextMessageSlice.ids, + }, + messageByThreadId: { + ...nextState.messageByThreadId, + [nextThread.id]: nextMessageSlice.byId, + }, + }; + } + + if (previousThread?.activities !== nextThread.activities) { + const nextActivitySlice = buildActivitySlice(nextThread); + nextState = { + ...nextState, + activityIdsByThreadId: { + ...nextState.activityIdsByThreadId, + [nextThread.id]: nextActivitySlice.ids, + }, + activityByThreadId: { + ...nextState.activityByThreadId, + [nextThread.id]: nextActivitySlice.byId, + }, + }; + } + + if (previousThread?.proposedPlans !== nextThread.proposedPlans) { + const nextProposedPlanSlice = buildProposedPlanSlice(nextThread); + nextState = { + ...nextState, + proposedPlanIdsByThreadId: { + ...nextState.proposedPlanIdsByThreadId, + [nextThread.id]: nextProposedPlanSlice.ids, + }, + proposedPlanByThreadId: { + ...nextState.proposedPlanByThreadId, + [nextThread.id]: nextProposedPlanSlice.byId, + }, + }; + } + + if (previousThread?.turnDiffSummaries !== nextThread.turnDiffSummaries) { + const nextTurnDiffSlice = buildTurnDiffSlice(nextThread); + nextState = { + ...nextState, + turnDiffIdsByThreadId: { + ...nextState.turnDiffIdsByThreadId, + [nextThread.id]: nextTurnDiffSlice.ids, + }, + turnDiffSummaryByThreadId: { + ...nextState.turnDiffSummaryByThreadId, + [nextThread.id]: nextTurnDiffSlice.byId, + }, + }; + } + + if (!sidebarThreadSummariesEqual(previousSummary, nextSummary)) { + nextState = { + ...nextState, + sidebarThreadSummaryById: { + ...nextState.sidebarThreadSummaryById, + [nextThread.id]: nextSummary, + }, + }; + } + + return nextState; } -function buildSidebarThreadsById( - threads: ReadonlyArray, -): Record { - return Object.fromEntries( - threads.map((thread) => [thread.id, buildSidebarThreadSummary(thread)]), - ); +function removeThreadState(state: AppState, threadId: ThreadId): AppState { + const shell = state.threadShellById[threadId]; + if (!shell) { + return state; + } + + const nextThreadIds = removeId(state.threadIds, threadId); + const currentProjectThreadIds = state.threadIdsByProjectId[shell.projectId] ?? EMPTY_THREAD_IDS; + const nextProjectThreadIds = removeId(currentProjectThreadIds, threadId); + const nextThreadIdsByProjectId = + nextProjectThreadIds.length === 0 + ? (() => { + const { [shell.projectId]: _removed, ...rest } = state.threadIdsByProjectId; + return rest as Record; + })() + : { + ...state.threadIdsByProjectId, + [shell.projectId]: nextProjectThreadIds, + }; + + const { [threadId]: _removedShell, ...threadShellById } = state.threadShellById; + const { [threadId]: _removedSession, ...threadSessionById } = state.threadSessionById; + const { [threadId]: _removedTurnState, ...threadTurnStateById } = state.threadTurnStateById; + const { [threadId]: _removedMessageIds, ...messageIdsByThreadId } = state.messageIdsByThreadId; + const { [threadId]: _removedMessages, ...messageByThreadId } = state.messageByThreadId; + const { [threadId]: _removedActivityIds, ...activityIdsByThreadId } = state.activityIdsByThreadId; + const { [threadId]: _removedActivities, ...activityByThreadId } = state.activityByThreadId; + const { [threadId]: _removedPlanIds, ...proposedPlanIdsByThreadId } = + state.proposedPlanIdsByThreadId; + const { [threadId]: _removedPlans, ...proposedPlanByThreadId } = state.proposedPlanByThreadId; + const { [threadId]: _removedTurnDiffIds, ...turnDiffIdsByThreadId } = state.turnDiffIdsByThreadId; + const { [threadId]: _removedTurnDiffs, ...turnDiffSummaryByThreadId } = + state.turnDiffSummaryByThreadId; + const { [threadId]: _removedSidebarSummary, ...sidebarThreadSummaryById } = + state.sidebarThreadSummaryById; + + return { + ...state, + threadIds: nextThreadIds, + threadIdsByProjectId: nextThreadIdsByProjectId, + threadShellById, + threadSessionById, + threadTurnStateById, + messageIdsByThreadId, + messageByThreadId, + activityIdsByThreadId, + activityByThreadId, + proposedPlanIdsByThreadId, + proposedPlanByThreadId, + turnDiffIdsByThreadId, + turnDiffSummaryByThreadId, + sidebarThreadSummaryById, + }; } function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { @@ -366,10 +720,10 @@ function buildLatestTurn(params: { } function rebindTurnDiffSummariesForAssistantMessage( - turnDiffSummaries: ReadonlyArray, - turnId: Thread["turnDiffSummaries"][number]["turnId"], + turnDiffSummaries: ReadonlyArray, + turnId: TurnId, assistantMessageId: NonNullable["assistantMessageId"], -): Thread["turnDiffSummaries"] { +): TurnDiffSummary[] { let changed = false; const nextSummaries = turnDiffSummaries.map((summary) => { if (summary.turnId !== turnId || summary.assistantMessageId === assistantMessageId) { @@ -456,18 +810,18 @@ function retainThreadMessagesAfterRevert( } function retainThreadActivitiesAfterRevert( - activities: ReadonlyArray, + activities: ReadonlyArray, retainedTurnIds: ReadonlySet, -): Thread["activities"] { +): OrchestrationThreadActivity[] { return activities.filter( (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId), ); } function retainThreadProposedPlansAfterRevert( - proposedPlans: ReadonlyArray, + proposedPlans: ReadonlyArray, retainedTurnIds: ReadonlySet, -): Thread["proposedPlans"] { +): ProposedPlan[] { return proposedPlans.filter( (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), ); @@ -536,56 +890,113 @@ function updateThreadState( threadId: ThreadId, updater: (thread: Thread) => Thread, ): AppState { - let updatedThread: Thread | null = null; - const threads = updateThread(state.threads, threadId, (thread) => { - const nextThread = updater(thread); - if (nextThread !== thread) { - updatedThread = nextThread; - } - return nextThread; - }); - if (threads === state.threads || updatedThread === null) { + const currentThread = getThread(state, threadId); + if (!currentThread) { return state; } + const nextThread = updater(currentThread); + if (nextThread === currentThread) { + return state; + } + return writeThreadState(state, nextThread, currentThread); +} - const nextSummary = buildSidebarThreadSummary(updatedThread); - const previousSummary = state.sidebarThreadsById[threadId]; - const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary) - ? state.sidebarThreadsById - : { - ...state.sidebarThreadsById, - [threadId]: nextSummary, - }; +function buildProjectState( + projects: ReadonlyArray, +): Pick { + return { + projectIds: projects.map((project) => project.id), + projectById: Object.fromEntries( + projects.map((project) => [project.id, project] as const), + ) as Record, + }; +} - if (sidebarThreadsById === state.sidebarThreadsById) { - return { - ...state, - threads, - }; +function buildThreadState( + threads: ReadonlyArray, +): Pick< + AppState, + | "threadIds" + | "threadIdsByProjectId" + | "threadShellById" + | "threadSessionById" + | "threadTurnStateById" + | "messageIdsByThreadId" + | "messageByThreadId" + | "activityIdsByThreadId" + | "activityByThreadId" + | "proposedPlanIdsByThreadId" + | "proposedPlanByThreadId" + | "turnDiffIdsByThreadId" + | "turnDiffSummaryByThreadId" + | "sidebarThreadSummaryById" +> { + const threadIds: ThreadId[] = []; + const threadIdsByProjectId: Record = {}; + const threadShellById: Record = {}; + const threadSessionById: Record = {}; + const threadTurnStateById: Record = {}; + const messageIdsByThreadId: Record = {}; + const messageByThreadId: Record> = {}; + const activityIdsByThreadId: Record = {}; + const activityByThreadId: Record> = {}; + const proposedPlanIdsByThreadId: Record = {}; + const proposedPlanByThreadId: Record> = {}; + const turnDiffIdsByThreadId: Record = {}; + const turnDiffSummaryByThreadId: Record> = {}; + const sidebarThreadSummaryById: Record = {}; + + for (const thread of threads) { + threadIds.push(thread.id); + threadIdsByProjectId[thread.projectId] = [ + ...(threadIdsByProjectId[thread.projectId] ?? EMPTY_THREAD_IDS), + thread.id, + ]; + threadShellById[thread.id] = toThreadShell(thread); + threadSessionById[thread.id] = thread.session; + threadTurnStateById[thread.id] = toThreadTurnState(thread); + const messageSlice = buildMessageSlice(thread); + messageIdsByThreadId[thread.id] = messageSlice.ids; + messageByThreadId[thread.id] = messageSlice.byId; + const activitySlice = buildActivitySlice(thread); + activityIdsByThreadId[thread.id] = activitySlice.ids; + activityByThreadId[thread.id] = activitySlice.byId; + const proposedPlanSlice = buildProposedPlanSlice(thread); + proposedPlanIdsByThreadId[thread.id] = proposedPlanSlice.ids; + proposedPlanByThreadId[thread.id] = proposedPlanSlice.byId; + const turnDiffSlice = buildTurnDiffSlice(thread); + turnDiffIdsByThreadId[thread.id] = turnDiffSlice.ids; + turnDiffSummaryByThreadId[thread.id] = turnDiffSlice.byId; + sidebarThreadSummaryById[thread.id] = buildSidebarThreadSummary(thread); } return { - ...state, - threads, - sidebarThreadsById, + threadIds, + threadIdsByProjectId, + threadShellById, + threadSessionById, + threadTurnStateById, + messageIdsByThreadId, + messageByThreadId, + activityIdsByThreadId, + activityByThreadId, + proposedPlanIdsByThreadId, + proposedPlanByThreadId, + turnDiffIdsByThreadId, + turnDiffSummaryByThreadId, + sidebarThreadSummaryById, }; } -// ── Pure state transition functions ──────────────────────────────────── - export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { const projects = readModel.projects .filter((project) => project.deletedAt === null) .map(mapProject); const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); - const sidebarThreadsById = buildSidebarThreadsById(threads); - const threadIdsByProjectId = buildThreadIdsByProjectId(threads); return { ...state, - projects, - threads, - sidebarThreadsById, - threadIdsByProjectId, + ...buildProjectState(projects), + ...buildThreadState(threads), bootstrapComplete: true, }; } @@ -593,10 +1004,6 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState { switch (event.type) { case "project.created": { - const existingIndex = state.projects.findIndex( - (project) => - project.id === event.payload.projectId || project.cwd === event.payload.workspaceRoot, - ); const nextProject = mapProject({ id: event.payload.projectId, title: event.payload.title, @@ -607,17 +1014,34 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.payload.updatedAt, deletedAt: null, }); - const projects = - existingIndex >= 0 - ? state.projects.map((project, index) => - index === existingIndex ? nextProject : project, - ) - : [...state.projects, nextProject]; - return { ...state, projects }; + const existingProjectId = + state.projectIds.find( + (projectId) => + projectId === event.payload.projectId || + state.projectById[projectId]?.cwd === event.payload.workspaceRoot, + ) ?? null; + const resolvedProjectId = existingProjectId ?? nextProject.id; + const projectById = { + ...state.projectById, + [resolvedProjectId]: nextProject, + }; + const projectIds = + existingProjectId === null && !state.projectIds.includes(nextProject.id) + ? [...state.projectIds, nextProject.id] + : state.projectIds; + return { + ...state, + projectById, + projectIds, + }; } case "project.meta-updated": { - const projects = updateProject(state.projects, event.payload.projectId, (project) => ({ + const project = state.projectById[event.payload.projectId]; + if (!project) { + return state; + } + const nextProject: Project = { ...project, ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), @@ -632,17 +1056,30 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve ? { scripts: mapProjectScripts(event.payload.scripts) } : {}), updatedAt: event.payload.updatedAt, - })); - return projects === state.projects ? state : { ...state, projects }; + }; + return { + ...state, + projectById: { + ...state.projectById, + [event.payload.projectId]: nextProject, + }, + }; } case "project.deleted": { - const projects = state.projects.filter((project) => project.id !== event.payload.projectId); - return projects.length === state.projects.length ? state : { ...state, projects }; + if (!state.projectById[event.payload.projectId]) { + return state; + } + const { [event.payload.projectId]: _removedProject, ...projectById } = state.projectById; + return { + ...state, + projectById, + projectIds: removeId(state.projectIds, event.payload.projectId), + }; } case "thread.created": { - const existing = state.threads.find((thread) => thread.id === event.payload.threadId); + const previousThread = getThread(state, event.payload.threadId); const nextThread = mapThread({ id: event.payload.threadId, projectId: event.payload.projectId, @@ -663,74 +1100,27 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve checkpoints: [], session: null, }); - const threads = existing - ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread)) - : [...state.threads, nextThread]; - const nextSummary = buildSidebarThreadSummary(nextThread); - const previousSummary = state.sidebarThreadsById[nextThread.id]; - const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary) - ? state.sidebarThreadsById - : { - ...state.sidebarThreadsById, - [nextThread.id]: nextSummary, - }; - const nextThreadIdsByProjectId = - existing !== undefined && existing.projectId !== nextThread.projectId - ? removeThreadIdByProjectId(state.threadIdsByProjectId, existing.projectId, existing.id) - : state.threadIdsByProjectId; - const threadIdsByProjectId = appendThreadIdByProjectId( - nextThreadIdsByProjectId, - nextThread.projectId, - nextThread.id, - ); - return { - ...state, - threads, - sidebarThreadsById, - threadIdsByProjectId, - }; + return writeThreadState(state, nextThread, previousThread); } - case "thread.deleted": { - const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId); - if (threads.length === state.threads.length) { - return state; - } - const deletedThread = state.threads.find((thread) => thread.id === event.payload.threadId); - const sidebarThreadsById = { ...state.sidebarThreadsById }; - delete sidebarThreadsById[event.payload.threadId]; - const threadIdsByProjectId = deletedThread - ? removeThreadIdByProjectId( - state.threadIdsByProjectId, - deletedThread.projectId, - deletedThread.id, - ) - : state.threadIdsByProjectId; - return { - ...state, - threads, - sidebarThreadsById, - threadIdsByProjectId, - }; - } + case "thread.deleted": + return removeThreadState(state, event.payload.threadId); - case "thread.archived": { + case "thread.archived": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, archivedAt: event.payload.archivedAt, updatedAt: event.payload.updatedAt, })); - } - case "thread.unarchived": { + case "thread.unarchived": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, archivedAt: null, updatedAt: event.payload.updatedAt, })); - } - case "thread.meta-updated": { + case "thread.meta-updated": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), @@ -743,25 +1133,22 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve : {}), updatedAt: event.payload.updatedAt, })); - } - case "thread.runtime-mode-set": { + case "thread.runtime-mode-set": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, runtimeMode: event.payload.runtimeMode, updatedAt: event.payload.updatedAt, })); - } - case "thread.interaction-mode-set": { + case "thread.interaction-mode-set": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, interactionMode: event.payload.interactionMode, updatedAt: event.payload.updatedAt, })); - } - case "thread.turn-start-requested": { + case "thread.turn-start-requested": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, ...(event.payload.modelSelection !== undefined @@ -772,7 +1159,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve pendingSourceProposedPlan: event.payload.sourceProposedPlan, updatedAt: event.occurredAt, })); - } case "thread.turn-interrupt-requested": { if (event.payload.turnId === undefined) { @@ -799,7 +1185,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve }); } - case "thread.message-sent": { + case "thread.message-sent": return updateThreadState(state, event.payload.threadId, (thread) => { const message = mapMessage({ id: event.payload.messageId, @@ -888,9 +1274,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - } - case "thread.session-set": { + case "thread.session-set": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, session: mapSession(event.payload.session), @@ -919,9 +1304,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve : thread.latestTurn, updatedAt: event.occurredAt, })); - } - case "thread.session-stop-requested": { + case "thread.session-stop-requested": return updateThreadState(state, event.payload.threadId, (thread) => thread.session === null ? thread @@ -937,9 +1321,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }, ); - } - case "thread.proposed-plan-upserted": { + case "thread.proposed-plan-upserted": return updateThreadState(state, event.payload.threadId, (thread) => { const proposedPlan = mapProposedPlan(event.payload.proposedPlan); const proposedPlans = [ @@ -957,9 +1340,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - } - case "thread.turn-diff-completed": { + case "thread.turn-diff-completed": return updateThreadState(state, event.payload.threadId, (thread) => { const checkpoint = mapTurnDiffSummary({ turnId: event.payload.turnId, @@ -1006,9 +1388,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - } - case "thread.reverted": { + case "thread.reverted": return updateThreadState(state, event.payload.threadId, (thread) => { const turnDiffSummaries = thread.turnDiffSummaries .filter( @@ -1058,9 +1439,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - } - case "thread.activity-appended": { + case "thread.activity-appended": return updateThreadState(state, event.payload.threadId, (thread) => { const activities = [ ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), @@ -1074,7 +1454,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - } case "thread.approval-response-requested": case "thread.user-input-response-requested": @@ -1094,30 +1473,29 @@ export function applyOrchestrationEvents( return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state); } +export const selectProjects = (state: AppState): Project[] => getProjects(state); +export const selectThreads = (state: AppState): Thread[] => getThreads(state); export const selectProjectById = (projectId: Project["id"] | null | undefined) => (state: AppState): Project | undefined => - projectId ? state.projects.find((project) => project.id === projectId) : undefined; - + projectId ? state.projectById[projectId] : undefined; export const selectThreadById = (threadId: ThreadId | null | undefined) => (state: AppState): Thread | undefined => - threadId ? state.threads.find((thread) => thread.id === threadId) : undefined; - + threadId ? getThread(state, threadId) : undefined; export const selectSidebarThreadSummaryById = (threadId: ThreadId | null | undefined) => (state: AppState): SidebarThreadSummary | undefined => - threadId ? state.sidebarThreadsById[threadId] : undefined; - + threadId ? state.sidebarThreadSummaryById[threadId] : undefined; export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => (state: AppState): ThreadId[] => projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { - return updateThreadState(state, threadId, (t) => { - if (t.error === error) return t; - return { ...t, error }; + return updateThreadState(state, threadId, (thread) => { + if (thread.error === error) return thread; + return { ...thread, error }; }); } @@ -1127,11 +1505,11 @@ export function setThreadBranch( branch: string | null, worktreePath: string | null, ): AppState { - return updateThreadState(state, threadId, (t) => { - if (t.branch === branch && t.worktreePath === worktreePath) return t; - const cwdChanged = t.worktreePath !== worktreePath; + return updateThreadState(state, threadId, (thread) => { + if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; + const cwdChanged = thread.worktreePath !== worktreePath; return { - ...t, + ...thread, branch, worktreePath, ...(cwdChanged ? { session: null } : {}), @@ -1139,8 +1517,6 @@ export function setThreadBranch( }); } -// ── Zustand store ──────────────────────────────────────────────────── - interface AppStore extends AppState { syncServerReadModel: (readModel: OrchestrationReadModel) => void; applyOrchestrationEvent: (event: OrchestrationEvent) => void; diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index 65f8e6caaa..a7a7440eb2 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,26 +1,146 @@ -import { type ThreadId } from "@t3tools/contracts"; -import { useMemo } from "react"; +import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts"; +import { type AppState } from "./store"; import { - selectProjectById, - selectSidebarThreadSummaryById, - selectThreadById, - useStore, -} from "./store"; -import { type Project, type SidebarThreadSummary, type Thread } from "./types"; - -export function useProjectById(projectId: Project["id"] | null | undefined): Project | undefined { - const selector = useMemo(() => selectProjectById(projectId), [projectId]); - return useStore(selector); + type ChatMessage, + type Project, + type ProposedPlan, + type SidebarThreadSummary, + type Thread, + type ThreadSession, + type ThreadTurnState, + type TurnDiffSummary, +} from "./types"; + +const EMPTY_MESSAGES: ChatMessage[] = []; +const EMPTY_ACTIVITIES: Thread["activities"] = []; +const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; +const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; + +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] : []; + }); } -export function useThreadById(threadId: ThreadId | null | undefined): Thread | undefined { - const selector = useMemo(() => selectThreadById(threadId), [threadId]); - return useStore(selector); +export function createProjectSelector( + projectId: ProjectId | null | undefined, +): (state: AppState) => Project | undefined { + return (state) => (projectId ? state.projectById[projectId] : undefined); +} + +export function createSidebarThreadSummarySelector( + threadId: ThreadId | null | undefined, +): (state: AppState) => SidebarThreadSummary | undefined { + return (state) => (threadId ? state.sidebarThreadSummaryById[threadId] : undefined); } -export function useSidebarThreadSummaryById( +export function createThreadSelector( threadId: ThreadId | null | undefined, -): SidebarThreadSummary | undefined { - const selector = useMemo(() => selectSidebarThreadSummaryById(threadId), [threadId]); - return useStore(selector); +): (state: AppState) => Thread | undefined { + let previousShell: AppState["threadShellById"][ThreadId] | undefined; + let previousSession: ThreadSession | null | undefined; + let previousTurnState: ThreadTurnState | undefined; + let previousMessageIds: MessageId[] | undefined; + let previousMessagesById: AppState["messageByThreadId"][ThreadId] | undefined; + let previousActivityIds: string[] | undefined; + let previousActivitiesById: AppState["activityByThreadId"][ThreadId] | undefined; + let previousProposedPlanIds: string[] | undefined; + let previousProposedPlansById: AppState["proposedPlanByThreadId"][ThreadId] | undefined; + let previousTurnDiffIds: TurnId[] | undefined; + let previousTurnDiffsById: AppState["turnDiffSummaryByThreadId"][ThreadId] | undefined; + let previousThread: Thread | undefined; + + return (state) => { + if (!threadId) { + return undefined; + } + + const shell = state.threadShellById[threadId]; + if (!shell) { + return undefined; + } + + const session = state.threadSessionById[threadId] ?? null; + const turnState = state.threadTurnStateById[threadId]; + const messageIds = state.messageIdsByThreadId[threadId]; + const messageById = state.messageByThreadId[threadId]; + const activityIds = state.activityIdsByThreadId[threadId]; + const activityById = state.activityByThreadId[threadId]; + const proposedPlanIds = state.proposedPlanIdsByThreadId[threadId]; + const proposedPlanById = state.proposedPlanByThreadId[threadId]; + const turnDiffIds = state.turnDiffIdsByThreadId[threadId]; + const turnDiffById = state.turnDiffSummaryByThreadId[threadId]; + + if ( + previousThread && + previousShell === shell && + previousSession === session && + previousTurnState === turnState && + previousMessageIds === messageIds && + previousMessagesById === messageById && + previousActivityIds === activityIds && + previousActivitiesById === activityById && + previousProposedPlanIds === proposedPlanIds && + previousProposedPlansById === proposedPlanById && + previousTurnDiffIds === turnDiffIds && + previousTurnDiffsById === turnDiffById + ) { + return previousThread; + } + + const nextThread: Thread = { + ...shell, + session, + latestTurn: turnState?.latestTurn ?? null, + pendingSourceProposedPlan: turnState?.pendingSourceProposedPlan, + messages: collectByIds(messageIds, messageById) as Thread["messages"] extends ChatMessage[] + ? ChatMessage[] + : never, + activities: collectByIds(activityIds, activityById) as Thread["activities"] extends Array< + infer _ + > + ? Thread["activities"] + : never, + proposedPlans: collectByIds( + proposedPlanIds, + proposedPlanById, + ) as Thread["proposedPlans"] extends ProposedPlan[] ? ProposedPlan[] : never, + turnDiffSummaries: collectByIds( + turnDiffIds, + turnDiffById, + ) as Thread["turnDiffSummaries"] extends TurnDiffSummary[] ? TurnDiffSummary[] : never, + }; + + previousShell = shell; + previousSession = session; + previousTurnState = turnState; + previousMessageIds = messageIds; + previousMessagesById = messageById; + previousActivityIds = activityIds; + previousActivitiesById = activityById; + previousProposedPlanIds = proposedPlanIds; + previousProposedPlansById = proposedPlanById; + previousTurnDiffIds = turnDiffIds; + previousTurnDiffsById = turnDiffById; + previousThread = { + ...nextThread, + messages: nextThread.messages.length === 0 ? EMPTY_MESSAGES : nextThread.messages, + activities: nextThread.activities.length === 0 ? EMPTY_ACTIVITIES : nextThread.activities, + proposedPlans: + nextThread.proposedPlans.length === 0 ? EMPTY_PROPOSED_PLANS : nextThread.proposedPlans, + turnDiffSummaries: + nextThread.turnDiffSummaries.length === 0 + ? EMPTY_TURN_DIFF_SUMMARIES + : nextThread.turnDiffSummaries, + }; + return previousThread; + }; } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 0599b9c989..972cf42bab 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -111,6 +111,27 @@ export interface Thread { activities: OrchestrationThreadActivity[]; } +export interface ThreadShell { + id: ThreadId; + codexThreadId: string | null; + projectId: ProjectId; + title: string; + modelSelection: ModelSelection; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + error: string | null; + createdAt: string; + archivedAt: string | null; + updatedAt?: string | undefined; + branch: string | null; + worktreePath: string | null; +} + +export interface ThreadTurnState { + latestTurn: OrchestrationLatestTurn | null; + pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; +} + export interface SidebarThreadSummary { id: ThreadId; projectId: ProjectId; From 0737ff435c36d33a2e95a12284dd94bba053318d Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:25:55 +0100 Subject: [PATCH 02/48] Fix project recreation keys and update web tests --- .../web/src/components/ChatView.logic.test.ts | 204 ++++++++++++------ .../components/chat/MessagesTimeline.test.tsx | 2 +- apps/web/src/store.test.ts | 8 +- apps/web/src/store.ts | 32 ++- 4 files changed, 171 insertions(+), 75 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 8d49bc07f2..cad565247d 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,6 +1,7 @@ import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useStore } from "../store"; +import { type Thread } from "../types"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, @@ -178,7 +179,7 @@ const makeThread = (input?: { startedAt: string | null; completedAt: string | null; } | null; -}) => ({ +}): Thread => ({ id: input?.id ?? ThreadId.makeUnsafe("thread-1"), codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), @@ -205,94 +206,172 @@ const makeThread = (input?: { activities: [], }); +function setStoreThreads(threads: ReadonlyArray>) { + const projectId = ProjectId.makeUnsafe("project-1"); + useStore.setState({ + projectIds: [projectId], + projectById: { + [projectId]: { + id: projectId, + name: "Project", + cwd: "/tmp/project", + defaultModelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + createdAt: "2026-03-29T00:00:00.000Z", + updatedAt: "2026-03-29T00:00:00.000Z", + scripts: [], + }, + }, + threadIds: threads.map((thread) => thread.id), + threadIdsByProjectId: { + [projectId]: threads.map((thread) => thread.id), + }, + threadShellById: Object.fromEntries( + threads.map((thread) => [ + thread.id, + { + id: thread.id, + codexThreadId: thread.codexThreadId, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + error: thread.error, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + ]), + ), + threadSessionById: Object.fromEntries(threads.map((thread) => [thread.id, thread.session])), + threadTurnStateById: Object.fromEntries( + threads.map((thread) => [ + thread.id, + { + latestTurn: thread.latestTurn, + ...(thread.pendingSourceProposedPlan + ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } + : {}), + }, + ]), + ), + messageIdsByThreadId: Object.fromEntries( + threads.map((thread) => [thread.id, thread.messages.map((message) => message.id)]), + ), + messageByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.messages.map((message) => [message.id, message])), + ]), + ), + activityIdsByThreadId: Object.fromEntries( + threads.map((thread) => [thread.id, thread.activities.map((activity) => activity.id)]), + ), + activityByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.activities.map((activity) => [activity.id, activity])), + ]), + ), + proposedPlanIdsByThreadId: Object.fromEntries( + threads.map((thread) => [thread.id, thread.proposedPlans.map((plan) => plan.id)]), + ), + proposedPlanByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.proposedPlans.map((plan) => [plan.id, plan])), + ]), + ), + turnDiffIdsByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + thread.turnDiffSummaries.map((summary) => summary.turnId), + ]), + ), + turnDiffSummaryByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.turnDiffSummaries.map((summary) => [summary.turnId, summary])), + ]), + ), + sidebarThreadSummaryById: {}, + bootstrapComplete: true, + }); +} + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - useStore.setState((state) => ({ - ...state, - projects: [], - threads: [], - bootstrapComplete: true, - })); + setStoreThreads([]); }); describe("waitForStartedServerThread", () => { it("resolves immediately when the thread is already started", async () => { const threadId = ThreadId.makeUnsafe("thread-started"); - useStore.setState((state) => ({ - ...state, - threads: [ - makeThread({ - id: threadId, - latestTurn: { - turnId: TurnId.makeUnsafe("turn-started"), - state: "running", - requestedAt: "2026-03-29T00:00:01.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: null, - }, - }), - ], - })); + setStoreThreads([ + makeThread({ + id: threadId, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-started"), + state: "running", + requestedAt: "2026-03-29T00:00:01.000Z", + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: null, + }, + }), + ]); await expect(waitForStartedServerThread(threadId)).resolves.toBe(true); }); it("waits for the thread to start via subscription updates", async () => { const threadId = ThreadId.makeUnsafe("thread-wait"); - useStore.setState((state) => ({ - ...state, - threads: [makeThread({ id: threadId })], - })); + setStoreThreads([makeThread({ id: threadId })]); const promise = waitForStartedServerThread(threadId, 500); - useStore.setState((state) => ({ - ...state, - threads: [ - makeThread({ - id: threadId, - latestTurn: { - turnId: TurnId.makeUnsafe("turn-started"), - state: "running", - requestedAt: "2026-03-29T00:00:01.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: null, - }, - }), - ], - })); + setStoreThreads([ + makeThread({ + id: threadId, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-started"), + state: "running", + requestedAt: "2026-03-29T00:00:01.000Z", + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: null, + }, + }), + ]); await expect(promise).resolves.toBe(true); }); it("handles the thread starting between the initial read and subscription setup", async () => { const threadId = ThreadId.makeUnsafe("thread-race"); - useStore.setState((state) => ({ - ...state, - threads: [makeThread({ id: threadId })], - })); + setStoreThreads([makeThread({ id: threadId })]); const originalSubscribe = useStore.subscribe.bind(useStore); let raced = false; vi.spyOn(useStore, "subscribe").mockImplementation((listener) => { if (!raced) { raced = true; - useStore.setState((state) => ({ - ...state, - threads: [ - makeThread({ - id: threadId, - latestTurn: { - turnId: TurnId.makeUnsafe("turn-race"), - state: "running", - requestedAt: "2026-03-29T00:00:01.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: null, - }, - }), - ], - })); + setStoreThreads([ + makeThread({ + id: threadId, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-race"), + state: "running", + requestedAt: "2026-03-29T00:00:01.000Z", + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: null, + }, + }), + ]); } return originalSubscribe(listener); }); @@ -304,10 +383,7 @@ describe("waitForStartedServerThread", () => { vi.useFakeTimers(); const threadId = ThreadId.makeUnsafe("thread-timeout"); - useStore.setState((state) => ({ - ...state, - threads: [makeThread({ id: threadId })], - })); + setStoreThreads([makeThread({ id: threadId })]); const promise = waitForStartedServerThread(threadId, 500); await vi.advanceTimersByTimeAsync(500); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74a..40d34b36c1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -95,7 +95,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Terminal 1 lines 1-5"); expect(markup).toContain("lucide-terminal"); expect(markup).toContain("yoo what's "); - }); + }, 10_000); it("renders context compaction entries in the normal work log", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 2294674848..05128905f0 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -491,6 +491,9 @@ describe("incremental orchestration updates", () => { expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId); expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project"); expect(projectsOf(next)[0]?.name).toBe("Project Recreated"); + expect(next.projectIds).toEqual([recreatedProjectId]); + expect(next.projectById[originalProjectId]).toBeUndefined(); + expect(next.projectById[recreatedProjectId]?.id).toBe(recreatedProjectId); }); it("removes stale project index entries when thread.created recreates a thread under a new project", () => { @@ -660,7 +663,10 @@ describe("incremental orchestration updates", () => { expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); - expect(threadsOf(next)[1]).toBe(thread2); + expect(next.threadShellById[thread2.id]).toBe(state.threadShellById[thread2.id]); + expect(next.threadSessionById[thread2.id]).toBe(state.threadSessionById[thread2.id]); + expect(next.messageIdsByThreadId[thread2.id]).toBe(state.messageIdsByThreadId[thread2.id]); + expect(next.messageByThreadId[thread2.id]).toBe(state.messageByThreadId[thread2.id]); }); it("applies replay batches in sequence and updates session state", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 318d92173c..236050ba75 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1020,15 +1020,29 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve projectId === event.payload.projectId || state.projectById[projectId]?.cwd === event.payload.workspaceRoot, ) ?? null; - const resolvedProjectId = existingProjectId ?? nextProject.id; - const projectById = { - ...state.projectById, - [resolvedProjectId]: nextProject, - }; - const projectIds = - existingProjectId === null && !state.projectIds.includes(nextProject.id) - ? [...state.projectIds, nextProject.id] - : state.projectIds; + let projectById = state.projectById; + let projectIds = state.projectIds; + + if (existingProjectId !== null && existingProjectId !== nextProject.id) { + const { [existingProjectId]: _removedProject, ...restProjectById } = state.projectById; + projectById = { + ...restProjectById, + [nextProject.id]: nextProject, + }; + projectIds = state.projectIds.map((projectId) => + projectId === existingProjectId ? nextProject.id : projectId, + ); + } else { + projectById = { + ...state.projectById, + [nextProject.id]: nextProject, + }; + projectIds = + existingProjectId === null && !state.projectIds.includes(nextProject.id) + ? [...state.projectIds, nextProject.id] + : state.projectIds; + } + return { ...state, projectById, From 6ff8f7101f957cbcf725822a7cecc63174fc3669 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:54:52 +0100 Subject: [PATCH 03/48] fix(web): stabilize browser test store selectors --- apps/web/src/components/ChatView.tsx | 105 +++--------------- .../components/GitActionsControl.browser.tsx | 8 +- 2 files changed, 22 insertions(+), 91 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 55fa361fe7..f362d92d7f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -82,8 +82,6 @@ import { type Thread, type TurnDiffSummary, } from "../types"; -import { LRUCache } from "../lib/lruCache"; - import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -210,95 +208,28 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record; -const MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES = 500; -const MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES = 512 * 1024; -const threadPlanCatalogCache = new LRUCache<{ - proposedPlans: Thread["proposedPlans"]; - entry: ThreadPlanCatalogEntry; -}>(MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES, MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES); - -function estimateThreadPlanCatalogEntrySize(thread: Pick): number { - return Math.max( - 64, - thread.id.length + - thread.proposedPlans.reduce( - (total, plan) => - total + - plan.id.length + - plan.planMarkdown.length + - plan.updatedAt.length + - (plan.turnId?.length ?? 0), - 0, - ), - ); -} - -function toThreadPlanCatalogEntry( - thread: Pick, -): ThreadPlanCatalogEntry { - const cached = threadPlanCatalogCache.get(thread.id); - if (cached && cached.proposedPlans === thread.proposedPlans) { - return cached.entry; - } - - const entry: ThreadPlanCatalogEntry = { - id: thread.id, - proposedPlans: thread.proposedPlans, - }; - threadPlanCatalogCache.set( - thread.id, - { - proposedPlans: thread.proposedPlans, - entry, - }, - estimateThreadPlanCatalogEntrySize(thread), - ); - return entry; -} - function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { - const selector = useMemo(() => { - let previousThreads: Array< - { id: ThreadId; proposedPlans: Thread["proposedPlans"] } | undefined - > | null = null; - let previousEntries: ThreadPlanCatalogEntry[] = []; - - return (state: { - threadShellById: Record; - proposedPlanIdsByThreadId: Record; - proposedPlanByThreadId: Record>; - }): ThreadPlanCatalogEntry[] => { - const nextThreads = threadIds.map((threadId) => { - if (!state.threadShellById[threadId]) { - return undefined; + const threadShellById = useStore((state) => state.threadShellById); + const proposedPlanIdsByThreadId = useStore((state) => state.proposedPlanIdsByThreadId); + const proposedPlanByThreadId = useStore((state) => state.proposedPlanByThreadId); + + return useMemo( + () => + threadIds.flatMap((threadId) => { + if (!threadShellById[threadId]) { + return []; } - return { - id: threadId, - proposedPlans: - state.proposedPlanIdsByThreadId[threadId]?.flatMap((planId) => { - const plan = state.proposedPlanByThreadId[threadId]?.[planId]; - return plan ? [plan] : []; - }) ?? [], - }; - }); - const cachedThreads = previousThreads; - if ( - cachedThreads && - nextThreads.length === cachedThreads.length && - nextThreads.every((thread, index) => thread === cachedThreads[index]) - ) { - return previousEntries; - } - previousThreads = nextThreads; - previousEntries = nextThreads.flatMap((thread) => - thread ? [toThreadPlanCatalogEntry(thread)] : [], - ); - return previousEntries; - }; - }, [threadIds]); + const proposedPlans = + proposedPlanIdsByThreadId[threadId]?.flatMap((planId) => { + const plan = proposedPlanByThreadId[threadId]?.[planId]; + return plan ? [plan] : []; + }) ?? []; - return useStore(selector); + return [{ id: threadId, proposedPlans }]; + }), + [proposedPlanByThreadId, proposedPlanIdsByThreadId, threadIds, threadShellById], + ); } function formatOutgoingPrompt(params: { diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index a975a65bbe..6b6d6cc23a 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -141,10 +141,10 @@ vi.mock("~/store", () => ({ useStore: (selector: (state: unknown) => unknown) => selector({ setThreadBranch: setThreadBranchSpy, - threads: [ - { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null }, - { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null }, - ], + threadShellById: { + [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null }, + [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null }, + }, }), })); From 8ccfe3ee5b740347b54dccdb7f1e837f9d790c0c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 12:48:13 -0700 Subject: [PATCH 04/48] Surface environment and repository identity metadata - Persist a stable server environment ID and descriptor - Resolve repository identity from git remotes and enrich orchestration events - Thread environment metadata through desktop and web startup flows --- apps/desktop/src/main.ts | 9 + apps/desktop/src/preload.ts | 8 + .../Layers/ServerEnvironment.test.ts | 33 ++++ .../environment/Layers/ServerEnvironment.ts | 99 ++++++++++ .../environment/Services/ServerEnvironment.ts | 13 ++ apps/server/src/git/Layers/GitManager.test.ts | 6 +- .../Layers/ProjectionSnapshotQuery.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.ts | 50 +++-- .../Layers/RepositoryIdentityResolver.test.ts | 76 ++++++++ .../Layers/RepositoryIdentityResolver.ts | 137 ++++++++++++++ .../Services/RepositoryIdentityResolver.ts | 12 ++ apps/server/src/server.test.ts | 173 +++++++++++++++++- apps/server/src/server.ts | 4 + apps/server/src/serverLifecycleEvents.test.ts | 10 + apps/server/src/serverRuntimeStartup.ts | 14 +- apps/server/src/ws.ts | 109 ++++++++--- apps/web/package.json | 1 + apps/web/src/components/ChatView.browser.tsx | 15 ++ .../components/KeybindingsToast.browser.tsx | 15 ++ .../settings/SettingsPanels.browser.tsx | 14 +- apps/web/src/environmentBootstrap.ts | 65 +++++++ apps/web/src/lib/utils.ts | 3 +- apps/web/src/rpc/serverState.test.ts | 18 ++ apps/web/src/types.ts | 6 + apps/web/src/wsNativeApi.test.ts | 16 ++ apps/web/src/wsRpcClient.ts | 5 +- apps/web/src/wsTransport.test.ts | 21 +++ bun.lock | 15 ++ packages/client-runtime/package.json | 25 +++ packages/client-runtime/src/index.ts | 2 + .../src/knownEnvironment.test.ts | 46 +++++ .../client-runtime/src/knownEnvironment.ts | 50 +++++ packages/client-runtime/src/scoped.ts | 60 ++++++ packages/client-runtime/tsconfig.json | 4 + packages/contracts/src/baseSchemas.ts | 2 + packages/contracts/src/environment.ts | 77 ++++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 6 + packages/contracts/src/orchestration.ts | 4 + packages/contracts/src/server.ts | 4 + packages/shared/src/git.test.ts | 31 +++- packages/shared/src/git.ts | 38 ++++ 42 files changed, 1250 insertions(+), 48 deletions(-) create mode 100644 apps/server/src/environment/Layers/ServerEnvironment.test.ts create mode 100644 apps/server/src/environment/Layers/ServerEnvironment.ts create mode 100644 apps/server/src/environment/Services/ServerEnvironment.ts create mode 100644 apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts create mode 100644 apps/server/src/project/Layers/RepositoryIdentityResolver.ts create mode 100644 apps/server/src/project/Services/RepositoryIdentityResolver.ts create mode 100644 apps/web/src/environmentBootstrap.ts create mode 100644 packages/client-runtime/package.json create mode 100644 packages/client-runtime/src/index.ts create mode 100644 packages/client-runtime/src/knownEnvironment.test.ts create mode 100644 packages/client-runtime/src/knownEnvironment.ts create mode 100644 packages/client-runtime/src/scoped.ts create mode 100644 packages/client-runtime/tsconfig.json create mode 100644 packages/contracts/src/environment.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3d61571df9..de327d0ff8 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -60,6 +60,7 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; +const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SCHEME = "t3"; @@ -1172,6 +1173,14 @@ function registerIpcHandlers(): void { event.returnValue = backendWsUrl; }); + ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); + ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { + event.returnValue = { + label: "Local environment", + wsUrl: backendWsUrl || null, + } as const; + }); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3d59db1714..bd678844ef 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -13,12 +13,20 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; +const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => { const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL); return typeof result === "string" ? result : null; }, + getLocalEnvironmentBootstrap: () => { + const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); + if (typeof result !== "object" || result === null) { + return null; + } + return result as ReturnType; + }, pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), diff --git a/apps/server/src/environment/Layers/ServerEnvironment.test.ts b/apps/server/src/environment/Layers/ServerEnvironment.test.ts new file mode 100644 index 0000000000..35b6803fc9 --- /dev/null +++ b/apps/server/src/environment/Layers/ServerEnvironment.test.ts @@ -0,0 +1,33 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ServerEnvironment } from "../Services/ServerEnvironment.ts"; +import { ServerEnvironmentLive } from "./ServerEnvironment.ts"; + +const makeServerEnvironmentLayer = (baseDir: string) => + ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); + +it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { + it.effect("persists the environment id across service restarts", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-test-", + }); + + const first = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); + const second = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); + + expect(first.environmentId).toBe(second.environmentId); + expect(second.capabilities.repositoryIdentity).toBe(true); + }), + ); +}); diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts new file mode 100644 index 0000000000..2978af7dcb --- /dev/null +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -0,0 +1,99 @@ +import { randomUUID } from "node:crypto"; +import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; +import { version } from "../../../package.json" with { type: "json" }; + +const ENVIRONMENT_ID_FILENAME = "environment-id"; + +function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { + switch (process.platform) { + case "darwin": + return "darwin"; + case "linux": + return "linux"; + case "win32": + return "windows"; + default: + return "unknown"; + } +} + +function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] { + switch (process.arch) { + case "arm64": + return "arm64"; + case "x64": + return "x64"; + default: + return "other"; + } +} + +export const makeServerEnvironment = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + const environmentIdPath = path.join(serverConfig.stateDir, ENVIRONMENT_ID_FILENAME); + + const readPersistedEnvironmentId = Effect.gen(function* () { + const exists = yield* fileSystem + .exists(environmentIdPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return null; + } + + const raw = yield* fileSystem.readFileString(environmentIdPath).pipe( + Effect.orElseSucceed(() => ""), + Effect.map((value) => value.trim()), + ); + + return raw.length > 0 ? raw : null; + }); + + const persistEnvironmentId = (value: string) => + fileSystem.writeFileString(environmentIdPath, `${value}\n`); + + const environmentIdRaw = yield* readPersistedEnvironmentId.pipe( + Effect.flatMap((persisted) => { + if (persisted) { + return Effect.succeed(persisted); + } + + const generated = randomUUID(); + return persistEnvironmentId(generated).pipe(Effect.as(generated)); + }), + ); + + const environmentId = EnvironmentId.makeUnsafe(environmentIdRaw); + const cwdBaseName = path.basename(serverConfig.cwd).trim(); + const label = + serverConfig.mode === "desktop" + ? "Local environment" + : cwdBaseName.length > 0 + ? cwdBaseName + : "T3 environment"; + + const descriptor: ExecutionEnvironmentDescriptor = { + environmentId, + label, + platform: { + os: platformOs(), + arch: platformArch(), + }, + serverVersion: version, + capabilities: { + repositoryIdentity: true, + }, + }; + + return { + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed(descriptor), + } satisfies ServerEnvironmentShape; +}); + +export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment); diff --git a/apps/server/src/environment/Services/ServerEnvironment.ts b/apps/server/src/environment/Services/ServerEnvironment.ts new file mode 100644 index 0000000000..9cf432ca72 --- /dev/null +++ b/apps/server/src/environment/Services/ServerEnvironment.ts @@ -0,0 +1,13 @@ +import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface ServerEnvironmentShape { + readonly getEnvironmentId: Effect.Effect; + readonly getDescriptor: Effect.Effect; +} + +export class ServerEnvironment extends ServiceMap.Service< + ServerEnvironment, + ServerEnvironmentShape +>()("t3/environment/Services/ServerEnvironment") {} diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 005bdb5bc6..38cbd13014 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -854,7 +854,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), - 12_000, + 20_000, ); it.effect( @@ -962,7 +962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ), ).toBe(false); }), - 12_000, + 20_000, ); it.effect("status returns merged PR state when latest PR was merged", () => @@ -1685,7 +1685,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { false, ); }), - 12_000, + 20_000, ); it.effect( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index c038bc9d2c..95510f1a4e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -234,6 +234,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", + repositoryIdentity: null, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index da7c695674..ff44e20dbf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -38,6 +38,8 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; +import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, @@ -163,6 +165,8 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const repositoryIdentityResolver = yield* RepositoryIdentityResolver; + const repositoryIdentityResolutionConcurrency = 4; const listProjectRows = SqlSchema.findAll({ Request: Schema.Void, @@ -652,10 +656,22 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); } + const repositoryIdentities = new Map( + yield* Effect.forEach( + projectRows, + (row) => + repositoryIdentityResolver + .resolve(row.workspaceRoot) + .pipe(Effect.map((identity) => [row.projectId, identity] as const)), + { concurrency: repositoryIdentityResolutionConcurrency }, + ), + ); + const projects: ReadonlyArray = projectRows.map((row) => ({ id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, createdAt: row.createdAt, @@ -732,19 +748,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow", ), ), - Effect.map( - Option.map( - (row): OrchestrationProject => ({ - id: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModelSelection: row.defaultModelSelection, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - }), - ), + Effect.map((option) => option), + Effect.flatMap((option) => + Option.isNone(option) + ? Effect.succeed(Option.none()) + : repositoryIdentityResolver.resolve(option.value.workspaceRoot).pipe( + Effect.map((repositoryIdentity) => + Option.some({ + id: option.value.projectId, + title: option.value.title, + workspaceRoot: option.value.workspaceRoot, + repositoryIdentity, + defaultModelSelection: option.value.defaultModelSelection, + scripts: option.value.scripts, + createdAt: option.value.createdAt, + updatedAt: option.value.updatedAt, + deletedAt: option.value.deletedAt, + } satisfies OrchestrationProject), + ), + ), ), ); @@ -816,4 +838,4 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { export const OrchestrationProjectionSnapshotQueryLive = Layer.effect( ProjectionSnapshotQuery, makeProjectionSnapshotQuery, -); +).pipe(Layer.provideMerge(RepositoryIdentityResolverLive)); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts new file mode 100644 index 0000000000..8b1f4ce528 --- /dev/null +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -0,0 +1,76 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, FileSystem } from "effect"; + +import { runProcess } from "../../processRunner.ts"; +import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; +import { RepositoryIdentityResolverLive } from "./RepositoryIdentityResolver.ts"; + +const git = (cwd: string, args: ReadonlyArray) => + Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); + +it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { + it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(identity?.displayName).toBe("T3Tools/t3code"); + expect(identity?.provider).toBe("github"); + expect(identity?.owner).toBe("T3Tools"); + expect(identity?.name).toBe("t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + + it.effect("returns null for non-git folders and repos without remotes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-non-git-", + }); + const gitDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-no-remote-", + }); + + yield* git(gitDir, ["init"]); + + const resolver = yield* RepositoryIdentityResolver; + const nonGitIdentity = yield* resolver.resolve(nonGitDir); + const noRemoteIdentity = yield* resolver.resolve(gitDir); + + expect(nonGitIdentity).toBeNull(); + expect(noRemoteIdentity).toBeNull(); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + + it.effect("prefers upstream over origin when both remotes are configured", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-upstream-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); + yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity).not.toBeNull(); + expect(identity?.locator.remoteName).toBe("upstream"); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(identity?.displayName).toBe("T3Tools/t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); +}); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts new file mode 100644 index 0000000000..9d94518afe --- /dev/null +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -0,0 +1,137 @@ +import type { RepositoryIdentity } from "@t3tools/contracts"; +import { Effect, Layer, Ref } from "effect"; +import { runProcess } from "../../processRunner.ts"; +import { + normalizeGitRemoteUrl, + parseGitHubRepositoryNameWithOwnerFromRemoteUrl, +} from "@t3tools/shared/git"; + +import { + RepositoryIdentityResolver, + type RepositoryIdentityResolverShape, +} from "../Services/RepositoryIdentityResolver.ts"; + +function parseRemoteFetchUrls(stdout: string): Map { + const remotes = new Map(); + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + if (!match) continue; + const [, remoteName = "", remoteUrl = "", direction = ""] = match; + if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { + continue; + } + remotes.set(remoteName, remoteUrl); + } + return remotes; +} + +function pickPrimaryRemote( + remotes: ReadonlyMap, +): { readonly remoteName: string; readonly remoteUrl: string } | null { + for (const preferredRemoteName of ["upstream", "origin"] as const) { + const remoteUrl = remotes.get(preferredRemoteName); + if (remoteUrl) { + return { remoteName: preferredRemoteName, remoteUrl }; + } + } + + const [remoteName, remoteUrl] = + [...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? []; + return remoteName && remoteUrl ? { remoteName, remoteUrl } : null; +} + +function buildRepositoryIdentity(input: { + readonly remoteName: string; + readonly remoteUrl: string; +}): RepositoryIdentity { + const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); + const githubNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(input.remoteUrl); + const [owner, repositoryName] = githubNameWithOwner?.split("/") ?? []; + + return { + canonicalKey, + locator: { + source: "git-remote", + remoteName: input.remoteName, + remoteUrl: input.remoteUrl, + }, + ...(githubNameWithOwner ? { displayName: githubNameWithOwner } : {}), + ...(githubNameWithOwner ? { provider: "github" } : {}), + ...(owner ? { owner } : {}), + ...(repositoryName ? { name: repositoryName } : {}), + }; +} + +async function resolveRepositoryIdentity(cwd: string): Promise<{ + readonly cacheKey: string; + readonly identity: RepositoryIdentity | null; +}> { + let topLevel = cwd; + + try { + const topLevelResult = await runProcess("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { + allowNonZeroExit: true, + }); + if (topLevelResult.code !== 0) { + return { cacheKey: cwd, identity: null }; + } + + const candidate = topLevelResult.stdout.trim(); + if (candidate.length > 0) { + topLevel = candidate; + } + } catch { + return { cacheKey: cwd, identity: null }; + } + + try { + const remoteResult = await runProcess("git", ["-C", topLevel, "remote", "-v"], { + allowNonZeroExit: true, + }); + if (remoteResult.code !== 0) { + return { cacheKey: topLevel, identity: null }; + } + + const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); + return { + cacheKey: topLevel, + identity: remote ? buildRepositoryIdentity(remote) : null, + }; + } catch { + return { cacheKey: topLevel, identity: null }; + } +} + +export const makeRepositoryIdentityResolver = Effect.gen(function* () { + const cacheRef = yield* Ref.make(new Map()); + + const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( + "RepositoryIdentityResolver.resolve", + )(function* (cwd) { + const cache = yield* Ref.get(cacheRef); + const cached = cache.get(cwd); + if (cached !== undefined) { + return cached; + } + + const resolved = yield* Effect.promise(() => resolveRepositoryIdentity(cwd)); + yield* Ref.update(cacheRef, (current) => { + const next = new Map(current); + next.set(cwd, resolved.identity); + next.set(resolved.cacheKey, resolved.identity); + return next; + }); + return resolved.identity; + }); + + return { + resolve, + } satisfies RepositoryIdentityResolverShape; +}); + +export const RepositoryIdentityResolverLive = Layer.effect( + RepositoryIdentityResolver, + makeRepositoryIdentityResolver, +); diff --git a/apps/server/src/project/Services/RepositoryIdentityResolver.ts b/apps/server/src/project/Services/RepositoryIdentityResolver.ts new file mode 100644 index 0000000000..2847cbca11 --- /dev/null +++ b/apps/server/src/project/Services/RepositoryIdentityResolver.ts @@ -0,0 +1,12 @@ +import type { RepositoryIdentity } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface RepositoryIdentityResolverShape { + readonly resolve: (cwd: string) => Effect.Effect; +} + +export class RepositoryIdentityResolver extends ServiceMap.Service< + RepositoryIdentityResolver, + RepositoryIdentityResolverShape +>()("t3/project/Services/RepositoryIdentityResolver") {} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 072e1ca172..6762a89129 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5,6 +5,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, DEFAULT_SERVER_SETTINGS, + EnvironmentId, + EventId, GitCommandError, KeybindingRule, MessageId, @@ -83,6 +85,14 @@ import { ProjectSetupScriptRunner, type ProjectSetupScriptRunnerShape, } from "./project/Services/ProjectSetupScriptRunner.ts"; +import { + RepositoryIdentityResolver, + type RepositoryIdentityResolverShape, +} from "./project/Services/RepositoryIdentityResolver.ts"; +import { + ServerEnvironment, + type ServerEnvironmentShape, +} from "./environment/Services/ServerEnvironment.ts"; import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; @@ -93,6 +103,18 @@ const defaultModelSelection = { provider: "codex", model: "gpt-5-codex", } as const; +const testEnvironmentDescriptor = { + environmentId: EnvironmentId.makeUnsafe("environment-test"), + label: "Test environment", + platform: { + os: "darwin" as const, + arch: "arm64" as const, + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +}; const makeDefaultOrchestrationReadModel = () => { const now = new Date().toISOString(); @@ -270,6 +292,8 @@ const buildAppUnderTest = (options?: { browserTraceCollector?: Partial; serverLifecycleEvents?: Partial; serverRuntimeStartup?: Partial; + serverEnvironment?: Partial; + repositoryIdentityResolver?: Partial; }; }) => Effect.gen(function* () { @@ -416,6 +440,19 @@ const buildAppUnderTest = (options?: { ...options?.layers?.serverRuntimeStartup, }), ), + Layer.provide( + Layer.mock(ServerEnvironment)({ + getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), + getDescriptor: Effect.succeed(testEnvironmentDescriptor), + ...options?.layers?.serverEnvironment, + }), + ), + Layer.provide( + Layer.mock(RepositoryIdentityResolver)({ + resolve: () => Effect.succeed(null), + ...options?.layers?.repositoryIdentityResolver, + }), + ), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layerConfig), @@ -1069,6 +1106,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { sequence: 1, type: "welcome" as const, payload: { + environment: testEnvironmentDescriptor, cwd: "/tmp/project", projectName: "project", }, @@ -1078,7 +1116,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { version: 1 as const, sequence: 2, type: "ready" as const, - payload: { at: new Date().toISOString() }, + payload: { at: new Date().toISOString(), environment: testEnvironmentDescriptor }, }); yield* buildAppUnderTest({ @@ -1996,6 +2034,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("enriches replayed project events with repository identity metadata", () => + Effect.gen(function* () { + const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:T3Tools/t3code.git", + }, + displayName: "T3Tools/t3code", + provider: "github", + owner: "T3Tools", + name: "t3code", + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + readEvents: (_fromSequenceExclusive) => + Stream.make({ + sequence: 1, + eventId: EventId.makeUnsafe("event-1"), + aggregateKind: "project", + aggregateId: defaultProjectId, + occurredAt: "2026-04-05T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.created", + payload: { + projectId: defaultProjectId, + title: "Default Project", + workspaceRoot: "/tmp/default-project", + defaultModelSelection, + scripts: [], + createdAt: "2026-04-05T00:00:00.000Z", + updatedAt: "2026-04-05T00:00:00.000Z", + }, + } satisfies Extract), + }, + repositoryIdentityResolver: { + resolve: () => Effect.succeed(repositoryIdentity), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const replayResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.replayEvents]({ + fromSequenceExclusive: 0, + }), + ), + ); + + const replayedEvent = replayResult[0]; + assert.equal(replayedEvent?.type, "project.created"); + assert.deepEqual( + replayedEvent && replayedEvent.type === "project.created" + ? replayedEvent.payload.repositoryIdentity + : null, + repositoryIdentity, + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("closes thread terminals after a successful archive command", () => Effect.gen(function* () { const threadId = ThreadId.makeUnsafe("thread-archive"); @@ -2498,6 +2603,72 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("enriches subscribed project meta updates with repository identity metadata", () => + Effect.gen(function* () { + const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "upstream", + remoteUrl: "git@github.com:T3Tools/t3code.git", + }, + displayName: "T3Tools/t3code", + provider: "github", + owner: "T3Tools", + name: "t3code", + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + getReadModel: () => + Effect.succeed({ + ...makeDefaultOrchestrationReadModel(), + snapshotSequence: 0, + }), + streamDomainEvents: Stream.make({ + sequence: 1, + eventId: EventId.makeUnsafe("event-1"), + aggregateKind: "project", + aggregateId: defaultProjectId, + occurredAt: "2026-04-05T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.meta-updated", + payload: { + projectId: defaultProjectId, + title: "Renamed Project", + updatedAt: "2026-04-05T00:00:00.000Z", + }, + } satisfies Extract), + }, + repositoryIdentityResolver: { + resolve: () => Effect.succeed(repositoryIdentity), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const event = Array.from(events)[0]; + assert.equal(event?.type, "project.meta-updated"); + assert.deepEqual( + event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, + repositoryIdentity, + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc orchestration.getSnapshot errors", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1d6f6ac66e..d706d79b44 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -44,11 +44,13 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor" import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; import { ServerSettingsLive } from "./serverSettings"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver"; +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver"; import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; import { ObservabilityLive } from "./observability/Layers/Observability"; +import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -199,6 +201,8 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ServerSettingsLive), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLive), + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(ServerEnvironmentLive), // Misc. Layer.provideMerge(AnalyticsServiceLayerLive), diff --git a/apps/server/src/serverLifecycleEvents.test.ts b/apps/server/src/serverLifecycleEvents.test.ts index 1cd8c25c03..cfa5c553a9 100644 --- a/apps/server/src/serverLifecycleEvents.test.ts +++ b/apps/server/src/serverLifecycleEvents.test.ts @@ -1,3 +1,4 @@ +import { EnvironmentId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { assertTrue } from "@effect/vitest/utils"; import { Effect, Option } from "effect"; @@ -9,12 +10,20 @@ it.effect( () => Effect.gen(function* () { const lifecycleEvents = yield* ServerLifecycleEvents; + const environment = { + environmentId: EnvironmentId.makeUnsafe("environment-test"), + label: "Test environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }; const welcome = yield* lifecycleEvents .publish({ version: 1, type: "welcome", payload: { + environment, cwd: "/tmp/project", projectName: "project", }, @@ -29,6 +38,7 @@ it.effect( type: "ready", payload: { at: new Date().toISOString(), + environment, }, }) .pipe(Effect.timeoutOption("50 millis")); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 7c9231ac93..e94c322225 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -27,6 +27,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; import { ServerLifecycleEvents } from "./serverLifecycleEvents"; import { ServerSettingsService } from "./serverSettings"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; const isWildcardHost = (host: string | undefined): boolean => @@ -262,6 +263,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () { const orchestrationReactor = yield* OrchestrationReactor; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; + const serverEnvironment = yield* ServerEnvironment; const commandGate = yield* makeCommandGate; const httpListening = yield* Deferred.make(); @@ -308,7 +310,9 @@ const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: preparing welcome payload"); const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome); + const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: publishing welcome event", { + environmentId: environment.environmentId, cwd: welcome.cwd, projectName: welcome.projectName, bootstrapProjectId: welcome.bootstrapProjectId, @@ -319,7 +323,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { lifecycleEvents.publish({ version: 1, type: "welcome", - payload: welcome, + payload: { + environment, + ...welcome, + }, }), ); }).pipe( @@ -354,7 +361,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { lifecycleEvents.publish({ version: 1, type: "ready", - payload: { at: new Date().toISOString() }, + payload: { + at: new Date().toISOString(), + environment: yield* serverEnvironment.getDescriptor, + }, }), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ca096bff33..e493503c84 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -47,6 +47,8 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; +import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; const WsRpcLayer = WsRpcGroup.toLayer( Effect.gen(function* () { @@ -67,6 +69,8 @@ const WsRpcLayer = WsRpcGroup.toLayer( const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; + const repositoryIdentityResolver = yield* RepositoryIdentityResolver; + const serverEnvironment = yield* ServerEnvironment; const serverCommandId = (tag: string) => CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); @@ -113,6 +117,49 @@ const WsRpcLayer = WsRpcGroup.toLayer( }); }; + const enrichProjectEvent = ( + event: OrchestrationEvent, + ): Effect.Effect => { + switch (event.type) { + case "project.created": + return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( + Effect.map((repositoryIdentity) => ({ + ...event, + payload: { + ...event.payload, + repositoryIdentity, + }, + })), + ); + case "project.meta-updated": + return Effect.gen(function* () { + const workspaceRoot = + event.payload.workspaceRoot ?? + (yield* orchestrationEngine.getReadModel()).projects.find( + (project) => project.id === event.payload.projectId, + )?.workspaceRoot ?? + null; + if (workspaceRoot === null) { + return event; + } + + const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); + return { + ...event, + payload: { + ...event.payload, + repositoryIdentity, + }, + } satisfies OrchestrationEvent; + }); + default: + return Effect.succeed(event); + } + }; + + const enrichOrchestrationEvents = (events: ReadonlyArray) => + Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); + const dispatchBootstrapTurnStart = ( command: Extract, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => @@ -329,8 +376,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( const keybindingsConfig = yield* keybindings.loadConfigState; const providers = yield* providerRegistry.getProviders; const settings = yield* serverSettings.getSettings; + const environment = yield* serverEnvironment.getDescriptor; return { + environment, cwd: config.cwd, keybindingsConfigPath: config.keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, @@ -435,6 +484,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( ), ).pipe( Effect.map((events) => Array.from(events)), + Effect.flatMap(enrichOrchestrationEvents), Effect.mapError( (cause) => new OrchestrationReplayEventsError({ @@ -455,6 +505,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( orchestrationEngine.readEvents(fromSequenceExclusive), ).pipe( Effect.map((events) => Array.from(events)), + Effect.flatMap(enrichOrchestrationEvents), Effect.catch(() => Effect.succeed([] as Array)), ); const replayStream = Stream.fromIterable(replayEvents); @@ -470,33 +521,43 @@ const WsRpcLayer = WsRpcGroup.toLayer( return source.pipe( Stream.mapEffect((event) => - Ref.modify( - state, - ({ - nextSequence, - pendingBySequence, - }): [Array, SequenceState] => { - if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { - return [[], { nextSequence, pendingBySequence }]; - } + enrichProjectEvent(event).pipe( + Effect.flatMap((enrichedEvent) => + Ref.modify( + state, + ({ + nextSequence, + pendingBySequence, + }): [Array, SequenceState] => { + if ( + enrichedEvent.sequence < nextSequence || + pendingBySequence.has(enrichedEvent.sequence) + ) { + return [[], { nextSequence, pendingBySequence }]; + } - const updatedPending = new Map(pendingBySequence); - updatedPending.set(event.sequence, event); + const updatedPending = new Map(pendingBySequence); + updatedPending.set(enrichedEvent.sequence, enrichedEvent); - const emit: Array = []; - let expected = nextSequence; - for (;;) { - const expectedEvent = updatedPending.get(expected); - if (!expectedEvent) { - break; - } - emit.push(expectedEvent); - updatedPending.delete(expected); - expected += 1; - } + const emit: Array = []; + let expected = nextSequence; + for (;;) { + const expectedEvent = updatedPending.get(expected); + if (!expectedEvent) { + break; + } + emit.push(expectedEvent); + updatedPending.delete(expected); + expected += 1; + } - return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; - }, + return [ + emit, + { nextSequence: expected, pendingBySequence: updatedPending }, + ]; + }, + ), + ), ), ), Stream.flatMap((events) => Stream.fromIterable(events)), diff --git a/apps/web/package.json b/apps/web/package.json index 499943c3f0..d127743705 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", + "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 8aa15a06d5..1cdd7ec2e4 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -4,6 +4,7 @@ import "../index.css"; import { EventId, ORCHESTRATION_WS_METHODS, + EnvironmentId, type MessageId, type OrchestrationEvent, type OrchestrationReadModel, @@ -128,6 +129,13 @@ function isoAt(offsetSeconds: number): string { function createBaseServerConfig(): ServerConfig { return { + environment: { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], @@ -313,6 +321,13 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { snapshot, serverConfig: createBaseServerConfig(), welcome: { + environment: { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", projectName: "Project", bootstrapProjectId: PROJECT_ID, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 1ee13f460f..d645546042 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -2,6 +2,7 @@ import "../index.css"; import { DEFAULT_SERVER_SETTINGS, + EnvironmentId, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, @@ -49,6 +50,13 @@ const wsLink = ws.link(/ws(s)?:\/\/.*/); function createBaseServerConfig(): ServerConfig { return { + environment: { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], @@ -155,6 +163,13 @@ function buildFixture(): TestFixture { snapshot: createMinimalSnapshot(), serverConfig: createBaseServerConfig(), welcome: { + environment: { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", projectName: "Project", bootstrapProjectId: PROJECT_ID, diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index f0ea32d4be..14bb39972d 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -1,6 +1,11 @@ import "../../index.css"; -import { DEFAULT_SERVER_SETTINGS, type NativeApi, type ServerConfig } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + type NativeApi, + type ServerConfig, +} from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -12,6 +17,13 @@ import { GeneralSettingsPanel } from "./SettingsPanels"; function createBaseServerConfig(): ServerConfig { return { + environment: { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], diff --git a/apps/web/src/environmentBootstrap.ts b/apps/web/src/environmentBootstrap.ts new file mode 100644 index 0000000000..860c459edf --- /dev/null +++ b/apps/web/src/environmentBootstrap.ts @@ -0,0 +1,65 @@ +import { + createKnownEnvironmentFromWsUrl, + getKnownEnvironmentBaseUrl, + type KnownEnvironment, +} from "@t3tools/client-runtime"; +import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; + +function createKnownEnvironmentFromDesktopBootstrap( + bootstrap: DesktopEnvironmentBootstrap | null | undefined, +): KnownEnvironment | null { + if (!bootstrap?.wsUrl) { + return null; + } + + return createKnownEnvironmentFromWsUrl({ + id: `desktop:${bootstrap.label}`, + label: bootstrap.label, + source: "desktop-managed", + wsUrl: bootstrap.wsUrl, + }); +} + +export function getPrimaryKnownEnvironment(): KnownEnvironment | null { + const desktopEnvironment = createKnownEnvironmentFromDesktopBootstrap( + window.desktopBridge?.getLocalEnvironmentBootstrap(), + ); + if (desktopEnvironment) { + return desktopEnvironment; + } + + const legacyDesktopWsUrl = window.desktopBridge?.getWsUrl(); + if (typeof legacyDesktopWsUrl === "string" && legacyDesktopWsUrl.length > 0) { + return createKnownEnvironmentFromWsUrl({ + id: "desktop-legacy", + label: "Local environment", + source: "desktop-managed", + wsUrl: legacyDesktopWsUrl, + }); + } + + const configuredWsUrl = import.meta.env.VITE_WS_URL; + if (typeof configuredWsUrl === "string" && configuredWsUrl.length > 0) { + return createKnownEnvironmentFromWsUrl({ + id: "configured-primary", + label: "Primary environment", + source: "configured", + wsUrl: configuredWsUrl, + }); + } + + return createKnownEnvironmentFromWsUrl({ + id: "window-origin", + label: "Primary environment", + source: "window-origin", + wsUrl: window.location.origin, + }); +} + +export function resolvePrimaryEnvironmentBootstrapUrl(): string { + const baseUrl = getKnownEnvironmentBaseUrl(getPrimaryKnownEnvironment()); + if (!baseUrl) { + throw new Error("Unable to resolve a known environment bootstrap URL."); + } + return baseUrl; +} diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index e48f815461..5b0bcec4bd 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -4,6 +4,7 @@ import { type CxOptions, cx } from "class-variance-authority"; import { twMerge } from "tailwind-merge"; import * as Random from "effect/Random"; import * as Effect from "effect/Effect"; +import { resolvePrimaryEnvironmentBootstrapUrl } from "../environmentBootstrap"; export function cn(...inputs: CxOptions) { return twMerge(cx(inputs)); @@ -54,7 +55,7 @@ export const resolveServerUrl = (options?: { }): string => { const rawUrl = firstNonEmptyString( options?.url, - window.desktopBridge?.getWsUrl(), + resolvePrimaryEnvironmentBootstrapUrl(), import.meta.env.VITE_WS_URL, window.location.origin, ); diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts index 721ce25fb5..4eb198324d 100644 --- a/apps/web/src/rpc/serverState.test.ts +++ b/apps/web/src/rpc/serverState.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + EnvironmentId, ProjectId, ThreadId, type ServerConfig, @@ -50,7 +51,21 @@ const defaultProviders: ReadonlyArray = [ }, ]; +const baseEnvironment = { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { + os: "darwin" as const, + arch: "arm64" as const, + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +}; + const baseServerConfig: ServerConfig = { + environment: baseEnvironment, cwd: "/tmp/workspace", keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", keybindings: [], @@ -193,6 +208,7 @@ describe("serverState", () => { sequence: 1, type: "welcome", payload: { + environment: baseEnvironment, cwd: "/tmp/workspace", projectName: "t3-code", bootstrapProjectId: ProjectId.makeUnsafe("project-1"), @@ -201,6 +217,7 @@ describe("serverState", () => { }); expect(listener).toHaveBeenCalledWith({ + environment: baseEnvironment, cwd: "/tmp/workspace", projectName: "t3-code", bootstrapProjectId: ProjectId.makeUnsafe("project-1"), @@ -210,6 +227,7 @@ describe("serverState", () => { const lateListener = vi.fn(); const unsubscribeLate = onWelcome(lateListener); expect(lateListener).toHaveBeenCalledWith({ + environment: baseEnvironment, cwd: "/tmp/workspace", projectName: "t3-code", bootstrapProjectId: ProjectId.makeUnsafe("project-1"), diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 972cf42bab..a54b195428 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,7 +1,9 @@ import type { + EnvironmentId, ModelSelection, OrchestrationLatestTurn, OrchestrationProposedPlanId, + RepositoryIdentity, OrchestrationSessionStatus, OrchestrationThreadActivity, ProjectScript as ContractProjectScript, @@ -80,8 +82,10 @@ export interface TurnDiffSummary { export interface Project { id: ProjectId; + environmentId?: EnvironmentId | null; name: string; cwd: string; + repositoryIdentity?: RepositoryIdentity | null; defaultModelSelection: ModelSelection | null; createdAt?: string | undefined; updatedAt?: string | undefined; @@ -90,6 +94,7 @@ export interface Project { export interface Thread { id: ThreadId; + environmentId?: EnvironmentId | null; codexThreadId: string | null; projectId: ProjectId; title: string; @@ -134,6 +139,7 @@ export interface ThreadTurnState { export interface SidebarThreadSummary { id: ThreadId; + environmentId?: EnvironmentId | null; projectId: ProjectId; title: string; interactionMode: ProviderInteractionMode; diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index ae56f85991..fef6e0c5d6 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -2,6 +2,7 @@ import { CommandId, DEFAULT_SERVER_SETTINGS, type DesktopBridge, + EnvironmentId, EventId, type GitStatusResult, ProjectId, @@ -121,6 +122,7 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn function makeDesktopBridge(overrides: Partial = {}): DesktopBridge { return { getWsUrl: () => null, + getLocalEnvironmentBootstrap: () => null, pickFolder: async () => null, confirm: async () => true, setTheme: async () => undefined, @@ -157,7 +159,21 @@ const defaultProviders: ReadonlyArray = [ }, ]; +const baseEnvironment = { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { + os: "darwin" as const, + arch: "arm64" as const, + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +}; + const baseServerConfig: ServerConfig = { + environment: baseEnvironment, cwd: "/tmp/workspace", keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", keybindings: [], diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 997b83d2d7..0eb37e3a2b 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -12,6 +12,7 @@ import { import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; import { Effect, Stream } from "effect"; +import { resolvePrimaryEnvironmentBootstrapUrl } from "./environmentBootstrap"; import { type WsRpcProtocolClient } from "./rpc/protocol"; import { resetWsReconnectBackoff } from "./rpc/wsConnectionState"; import { WsTransport } from "./wsTransport"; @@ -124,7 +125,9 @@ export async function __resetWsRpcClientForTests() { sharedWsRpcClient = null; } -export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { +export function createWsRpcClient( + transport = new WsTransport(resolvePrimaryEnvironmentBootstrapUrl()), +): WsRpcClient { return { dispose: () => transport.dispose(), reconnect: async () => { diff --git a/apps/web/src/wsTransport.test.ts b/apps/web/src/wsTransport.test.ts index da5404b239..58453d9913 100644 --- a/apps/web/src/wsTransport.test.ts +++ b/apps/web/src/wsTransport.test.ts @@ -436,6 +436,13 @@ describe("WsTransport", () => { sequence: 1, type: "welcome", payload: { + environment: { + environmentId: "environment-local", + label: "Local environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/tmp/workspace", projectName: "workspace", }, @@ -489,6 +496,13 @@ describe("WsTransport", () => { sequence: 1, type: "welcome", payload: { + environment: { + environmentId: "environment-local", + label: "Local environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/tmp/one", projectName: "one", }, @@ -532,6 +546,13 @@ describe("WsTransport", () => { sequence: 2, type: "welcome", payload: { + environment: { + environmentId: "environment-local", + label: "Local environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/tmp/two", projectName: "two", }, diff --git a/bun.lock b/bun.lock index af243cf4eb..74c5badbe6 100644 --- a/bun.lock +++ b/bun.lock @@ -82,6 +82,7 @@ "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", + "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", @@ -121,6 +122,18 @@ "vitest-browser-react": "^2.0.5", }, }, + "packages/client-runtime": { + "name": "@t3tools/client-runtime", + "version": "0.0.0-alpha.1", + "dependencies": { + "@t3tools/contracts": "workspace:*", + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + }, "packages/contracts": { "name": "@t3tools/contracts", "version": "0.0.15", @@ -659,6 +672,8 @@ "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + "@t3tools/client-runtime": ["@t3tools/client-runtime@workspace:packages/client-runtime"], + "@t3tools/contracts": ["@t3tools/contracts@workspace:packages/contracts"], "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"], diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json new file mode 100644 index 0000000000..bfe2d7828e --- /dev/null +++ b/packages/client-runtime/package.json @@ -0,0 +1,25 @@ +{ + "name": "@t3tools/client-runtime", + "version": "0.0.0-alpha.1", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "prepare": "effect-language-service patch", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@t3tools/contracts": "workspace:*" + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts new file mode 100644 index 0000000000..5dd6b9afa5 --- /dev/null +++ b/packages/client-runtime/src/index.ts @@ -0,0 +1,2 @@ +export * from "./knownEnvironment"; +export * from "./scoped"; diff --git a/packages/client-runtime/src/knownEnvironment.test.ts b/packages/client-runtime/src/knownEnvironment.test.ts new file mode 100644 index 0000000000..70cf7996a0 --- /dev/null +++ b/packages/client-runtime/src/knownEnvironment.test.ts @@ -0,0 +1,46 @@ +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { createKnownEnvironmentFromWsUrl } from "./knownEnvironment"; +import { scopedRefKey, scopeProjectRef, scopeThreadRef } from "./scoped"; + +describe("known environment bootstrap helpers", () => { + it("creates known environments from explicit ws urls", () => { + expect( + createKnownEnvironmentFromWsUrl({ + label: "Remote environment", + wsUrl: "wss://remote.example.com/ws", + }), + ).toEqual({ + id: "ws:Remote environment", + label: "Remote environment", + source: "manual", + target: { + type: "ws", + wsUrl: "wss://remote.example.com/ws", + }, + }); + }); +}); + +describe("scoped refs", () => { + const environmentId = EnvironmentId.makeUnsafe("environment-test"); + const projectRef = scopeProjectRef(environmentId, ProjectId.makeUnsafe("project-1")); + const threadRef = scopeThreadRef(environmentId, ThreadId.makeUnsafe("thread-1")); + + it("builds stable scoped project and thread keys", () => { + expect(scopedRefKey(projectRef)).toBe("environment-test:project-1"); + expect(scopedRefKey(threadRef)).toBe("environment-test:thread-1"); + }); + + it("returns typed scoped refs", () => { + expect(projectRef).toEqual({ + environmentId, + projectId: ProjectId.makeUnsafe("project-1"), + }); + expect(threadRef).toEqual({ + environmentId, + threadId: ThreadId.makeUnsafe("thread-1"), + }); + }); +}); diff --git a/packages/client-runtime/src/knownEnvironment.ts b/packages/client-runtime/src/knownEnvironment.ts new file mode 100644 index 0000000000..40c9054f17 --- /dev/null +++ b/packages/client-runtime/src/knownEnvironment.ts @@ -0,0 +1,50 @@ +import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; + +export interface KnownEnvironmentConnectionTarget { + readonly type: "ws"; + readonly wsUrl: string; +} + +export type KnownEnvironmentSource = "configured" | "desktop-managed" | "manual" | "window-origin"; + +export interface KnownEnvironment { + readonly id: string; + readonly label: string; + readonly source: KnownEnvironmentSource; + readonly environmentId?: EnvironmentId; + readonly target: KnownEnvironmentConnectionTarget; +} + +export function createKnownEnvironmentFromWsUrl(input: { + readonly id?: string; + readonly label: string; + readonly source?: KnownEnvironmentSource; + readonly wsUrl: string; +}): KnownEnvironment { + return { + id: input.id ?? `ws:${input.label}`, + label: input.label, + source: input.source ?? "manual", + target: { + type: "ws", + wsUrl: input.wsUrl, + }, + }; +} + +export function getKnownEnvironmentBaseUrl( + environment: KnownEnvironment | null | undefined, +): string | null { + return environment?.target.wsUrl ?? null; +} + +export function attachEnvironmentDescriptor( + environment: KnownEnvironment, + descriptor: ExecutionEnvironmentDescriptor, +): KnownEnvironment { + return { + ...environment, + environmentId: descriptor.environmentId, + label: descriptor.label, + }; +} diff --git a/packages/client-runtime/src/scoped.ts b/packages/client-runtime/src/scoped.ts new file mode 100644 index 0000000000..54ffd6dcbb --- /dev/null +++ b/packages/client-runtime/src/scoped.ts @@ -0,0 +1,60 @@ +import type { + EnvironmentId, + ProjectId, + ScopedProjectRef, + ScopedThreadSessionRef, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; + +interface EnvironmentScopedRef { + readonly environmentId: EnvironmentId; + readonly id: TId; +} + +export interface EnvironmentClientRegistry { + readonly getClient: (environmentId: EnvironmentId) => TClient | null | undefined; +} + +export function scopeProjectRef( + environmentId: EnvironmentId, + projectId: ProjectId, +): ScopedProjectRef { + return { environmentId, projectId }; +} + +export function scopeThreadRef(environmentId: EnvironmentId, threadId: ThreadId): ScopedThreadRef { + return { environmentId, threadId }; +} + +export function scopeThreadSessionRef( + environmentId: EnvironmentId, + threadId: ThreadId, +): ScopedThreadSessionRef { + return { environmentId, threadId }; +} + +export function scopedRefKey( + ref: EnvironmentScopedRef | ScopedProjectRef | ScopedThreadRef | ScopedThreadSessionRef, +): string { + const localId = "id" in ref ? ref.id : "projectId" in ref ? ref.projectId : ref.threadId; + return `${ref.environmentId}:${localId}`; +} + +export function resolveEnvironmentClient( + registry: EnvironmentClientRegistry, + ref: EnvironmentScopedRef, +): TClient { + const client = registry.getClient(ref.environmentId); + if (!client) { + throw new Error(`No client registered for environment ${ref.environmentId}.`); + } + return client; +} + +export function tagEnvironmentValue( + environmentId: EnvironmentId, + value: T, +): { readonly environmentId: EnvironmentId; readonly value: T } { + return { environmentId, value }; +} diff --git a/packages/client-runtime/tsconfig.json b/packages/client-runtime/tsconfig.json new file mode 100644 index 0000000000..564a599005 --- /dev/null +++ b/packages/client-runtime/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 24962aed69..5a199e9a67 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -19,6 +19,8 @@ export const ThreadId = makeEntityId("ThreadId"); export type ThreadId = typeof ThreadId.Type; export const ProjectId = makeEntityId("ProjectId"); export type ProjectId = typeof ProjectId.Type; +export const EnvironmentId = makeEntityId("EnvironmentId"); +export type EnvironmentId = typeof EnvironmentId.Type; export const CommandId = makeEntityId("CommandId"); export type CommandId = typeof CommandId.Type; export const EventId = makeEntityId("EventId"); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts new file mode 100644 index 0000000000..9e97be83ea --- /dev/null +++ b/packages/contracts/src/environment.ts @@ -0,0 +1,77 @@ +import { Schema } from "effect"; + +import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; + +export const ExecutionEnvironmentPlatformOs = Schema.Literals([ + "darwin", + "linux", + "windows", + "unknown", +]); +export type ExecutionEnvironmentPlatformOs = typeof ExecutionEnvironmentPlatformOs.Type; + +export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", "other"]); +export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type; + +export const ExecutionEnvironmentPlatform = Schema.Struct({ + os: ExecutionEnvironmentPlatformOs, + arch: ExecutionEnvironmentPlatformArch, +}); +export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; + +export const ExecutionEnvironmentCapabilities = Schema.Struct({ + repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), +}); +export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; + +export const ExecutionEnvironmentDescriptor = Schema.Struct({ + environmentId: EnvironmentId, + label: TrimmedNonEmptyString, + platform: ExecutionEnvironmentPlatform, + serverVersion: TrimmedNonEmptyString, + capabilities: ExecutionEnvironmentCapabilities, +}); +export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type; + +export const EnvironmentConnectionState = Schema.Literals([ + "connecting", + "connected", + "disconnected", + "error", +]); +export type EnvironmentConnectionState = typeof EnvironmentConnectionState.Type; + +export const RepositoryIdentityLocator = Schema.Struct({ + source: Schema.Literal("git-remote"), + remoteName: TrimmedNonEmptyString, + remoteUrl: TrimmedNonEmptyString, +}); +export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; + +export const RepositoryIdentity = Schema.Struct({ + canonicalKey: TrimmedNonEmptyString, + locator: RepositoryIdentityLocator, + displayName: Schema.optionalKey(TrimmedNonEmptyString), + provider: Schema.optionalKey(TrimmedNonEmptyString), + owner: Schema.optionalKey(TrimmedNonEmptyString), + name: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type RepositoryIdentity = typeof RepositoryIdentity.Type; + +export const ScopedProjectRef = Schema.Struct({ + environmentId: EnvironmentId, + projectId: ProjectId, +}); +export type ScopedProjectRef = typeof ScopedProjectRef.Type; + +export const ScopedThreadRef = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, +}); +export type ScopedThreadRef = typeof ScopedThreadRef.Type; + +export const ScopedThreadSessionRef = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, +}); +export type ScopedThreadSessionRef = typeof ScopedThreadSessionRef.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c60856bbe5..d2f84eda9d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ export * from "./baseSchemas"; +export * from "./environment"; export * from "./ipc"; export * from "./terminal"; export * from "./provider"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 630ccd8249..4571f31dbe 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -105,8 +105,14 @@ export interface DesktopUpdateCheckResult { state: DesktopUpdateState; } +export interface DesktopEnvironmentBootstrap { + label: string; + wsUrl: string | null; +} + export interface DesktopBridge { getWsUrl: () => string | null; + getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 6c7f073612..e6e4a52106 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,5 +1,6 @@ import { Option, Schema, SchemaIssue, Struct } from "effect"; import { ClaudeModelOptions, CodexModelOptions } from "./model"; +import { RepositoryIdentity } from "./environment"; import { ApprovalRequestId, CheckpointRef, @@ -141,6 +142,7 @@ export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -647,6 +649,7 @@ export const ProjectCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -657,6 +660,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), + repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 776a0a89e9..9227f4d8c9 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import { ExecutionEnvironmentDescriptor } from "./environment"; import { IsoDateTime, NonNegativeInt, @@ -83,6 +84,7 @@ export const ServerObservability = Schema.Struct({ export type ServerObservability = typeof ServerObservability.Type; export const ServerConfig = Schema.Struct({ + environment: ExecutionEnvironmentDescriptor, cwd: TrimmedNonEmptyString, keybindingsConfigPath: TrimmedNonEmptyString, keybindings: ResolvedKeybindingsConfig, @@ -167,10 +169,12 @@ export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; export const ServerLifecycleReadyPayload = Schema.Struct({ at: IsoDateTime, + environment: ExecutionEnvironmentDescriptor, }); export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type; export const ServerLifecycleWelcomePayload = Schema.Struct({ + environment: ExecutionEnvironmentDescriptor, cwd: TrimmedNonEmptyString, projectName: TrimmedNonEmptyString, bootstrapProjectId: Schema.optional(ProjectId), diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 7beb7a75de..154acb0957 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -1,7 +1,11 @@ import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { applyGitStatusStreamEvent } from "./git"; +import { + applyGitStatusStreamEvent, + normalizeGitRemoteUrl, + parseGitHubRepositoryNameWithOwnerFromRemoteUrl, +} from "./git"; describe("applyGitStatusStreamEvent", () => { it("treats a remote-only update as a repository when local state is missing", () => { @@ -65,3 +69,28 @@ describe("applyGitStatusStreamEvent", () => { }); }); }); + +describe("normalizeGitRemoteUrl", () => { + it("canonicalizes equivalent GitHub remotes across protocol variants", () => { + expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("https://github.com/T3Tools/T3Code.git")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("ssh://git@github.com/T3Tools/T3Code")).toBe( + "github.com/t3tools/t3code", + ); + }); +}); + +describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { + it("extracts the owner and repository from common GitHub remote shapes", () => { + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:T3Tools/T3Code.git"), + ).toBe("T3Tools/T3Code"); + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"), + ).toBe("T3Tools/T3Code"); + }); +}); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 16171315b7..167be1e03d 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -80,6 +80,44 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { return branchName.slice(firstSeparatorIndex + 1); } +/** + * Normalize a git remote URL into a stable comparison key. + */ +export function normalizeGitRemoteUrl(value: string): string { + const normalized = value + .trim() + .replace(/\/+$/g, "") + .replace(/\.git$/i, "") + .toLowerCase(); + const hostAndPath = + /^(?:git@|ssh:\/\/git@|https:\/\/|http:\/\/|git:\/\/)([^/:]+)[:/]([^/\s]+\/[^/\s]+)$/i.exec( + normalized, + ); + + if (hostAndPath?.[1] && hostAndPath[2]) { + return `${hostAndPath[1]}/${hostAndPath[2]}`; + } + + return normalized; +} + +/** + * Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes. + */ +export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { + const trimmed = url?.trim() ?? ""; + if (trimmed.length === 0) { + return null; + } + + const match = + /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( + trimmed, + ); + const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; + return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; +} + function deriveLocalBranchNameCandidatesFromRemoteRef( branchName: string, remoteName?: string, From d1437f1c9bbe56d9a2d0fef79c8de24ee726b50c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 14:25:00 -0700 Subject: [PATCH 05/48] Include client-runtime in release smoke checks - Add packages/client-runtime/package.json to the release smoke workspace list --- scripts/release-smoke.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index bf9d9f5c6a..98f7da5789 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -13,6 +13,7 @@ const workspaceFiles = [ "apps/desktop/package.json", "apps/web/package.json", "apps/marketing/package.json", + "packages/client-runtime/package.json", "packages/contracts/package.json", "packages/shared/package.json", "scripts/package.json", From deb215ef30017a08bfabe4912ebbbd36117e2c4d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 09:30:50 -0700 Subject: [PATCH 06/48] Treat explicit null environments distinctly - keep remote-host project/thread lookups separate from the active env - avoid double-enriching replayed project events --- .../Layers/ProjectionSnapshotQuery.ts | 1 - apps/server/src/server.test.ts | 73 ++++++++++++ apps/server/src/ws.ts | 63 +++++------ apps/web/src/components/Sidebar.tsx | 105 +++++++++++++++--- apps/web/src/store.test.ts | 56 ++++++++++ apps/web/src/store.ts | 57 ++++++++++ 6 files changed, 306 insertions(+), 49 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index ff44e20dbf..008415f210 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -748,7 +748,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow", ), ), - Effect.map((option) => option), Effect.flatMap((option) => Option.isNone(option) ? Effect.succeed(Option.none()) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 6762a89129..125cfd103a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2603,6 +2603,79 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("enriches replayed project events only once before streaming them to subscribers", () => + Effect.gen(function* () { + let resolveCalls = 0; + const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:t3tools/t3code.git", + }, + displayName: "t3tools/t3code", + provider: "github" as const, + owner: "t3tools", + name: "t3code", + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + getReadModel: () => + Effect.succeed({ + ...makeDefaultOrchestrationReadModel(), + snapshotSequence: 0, + }), + readEvents: () => + Stream.make({ + sequence: 1, + eventId: EventId.makeUnsafe("event-1"), + aggregateKind: "project", + aggregateId: defaultProjectId, + occurredAt: "2026-04-06T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.meta-updated", + payload: { + projectId: defaultProjectId, + title: "Replayed Project", + updatedAt: "2026-04-06T00:00:00.000Z", + }, + } satisfies Extract), + streamDomainEvents: Stream.empty, + }, + repositoryIdentityResolver: { + resolve: () => { + resolveCalls += 1; + return Effect.succeed(repositoryIdentity); + }, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const event = Array.from(events)[0]; + assert.equal(resolveCalls, 1); + assert.equal(event?.type, "project.meta-updated"); + assert.deepEqual( + event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, + repositoryIdentity, + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("enriches subscribed project meta updates with repository identity metadata", () => Effect.gen(function* () { const repositoryIdentity = { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e493503c84..16e8531386 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -509,7 +509,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( Effect.catch(() => Effect.succeed([] as Array)), ); const replayStream = Stream.fromIterable(replayEvents); - const source = Stream.merge(replayStream, orchestrationEngine.streamDomainEvents); + const liveStream = orchestrationEngine.streamDomainEvents.pipe( + Stream.mapEffect(enrichProjectEvent), + ); + const source = Stream.merge(replayStream, liveStream); type SequenceState = { readonly nextSequence: number; readonly pendingBySequence: Map; @@ -521,43 +524,33 @@ const WsRpcLayer = WsRpcGroup.toLayer( return source.pipe( Stream.mapEffect((event) => - enrichProjectEvent(event).pipe( - Effect.flatMap((enrichedEvent) => - Ref.modify( - state, - ({ - nextSequence, - pendingBySequence, - }): [Array, SequenceState] => { - if ( - enrichedEvent.sequence < nextSequence || - pendingBySequence.has(enrichedEvent.sequence) - ) { - return [[], { nextSequence, pendingBySequence }]; - } + Ref.modify( + state, + ({ + nextSequence, + pendingBySequence, + }): [Array, SequenceState] => { + if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { + return [[], { nextSequence, pendingBySequence }]; + } - const updatedPending = new Map(pendingBySequence); - updatedPending.set(enrichedEvent.sequence, enrichedEvent); + const updatedPending = new Map(pendingBySequence); + updatedPending.set(event.sequence, event); - const emit: Array = []; - let expected = nextSequence; - for (;;) { - const expectedEvent = updatedPending.get(expected); - if (!expectedEvent) { - break; - } - emit.push(expectedEvent); - updatedPending.delete(expected); - expected += 1; - } + const emit: Array = []; + let expected = nextSequence; + for (;;) { + const expectedEvent = updatedPending.get(expected); + if (!expectedEvent) { + break; + } + emit.push(expectedEvent); + updatedPending.delete(expected); + expected += 1; + } - return [ - emit, - { nextSequence: expected, pendingBySequence: updatedPending }, - ]; - }, - ), - ), + return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; + }, ), ), Stream.flatMap((events) => Stream.fromIterable(events)), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 923d9d88e8..9bc21cf8b3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -58,7 +58,11 @@ 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"; +<<<<<<< HEAD import { useStore } from "../store"; +======= +import { getProjectScopedId, useStore } from "../store"; +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { @@ -128,7 +132,11 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; +<<<<<<< HEAD import type { Project } from "../types"; +======= +import type { Project, SidebarThreadSummary } from "../types"; +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -255,8 +263,12 @@ function resolveThreadPr( } interface SidebarThreadRowProps { +<<<<<<< HEAD threadId: ThreadId; projectCwd: string | null; +======= + thread: SidebarThreadSummary; +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) orderedProjectThreadIds: readonly ThreadId[]; routeThreadId: ThreadId | null; selectedThreadIds: ReadonlySet; @@ -279,7 +291,7 @@ interface SidebarThreadRowProps { navigateToThread: (threadId: ThreadId) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( - threadId: ThreadId, + thread: SidebarThreadSummary, position: { x: number; y: number }, ) => Promise; clearSelection: () => void; @@ -290,19 +302,20 @@ interface SidebarThreadRowProps { } function SidebarThreadRow(props: SidebarThreadRowProps) { +<<<<<<< HEAD const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); +======= + const { thread } = props; + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[thread.id]); +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) const runningTerminalIds = useTerminalStateStore( (state) => - selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, + selectThreadTerminalState(state.terminalStateByThreadId, thread.id).runningTerminalIds, ); const gitCwd = thread?.worktreePath ?? props.projectCwd; const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null); - if (!thread) { - return null; - } - const isActive = props.routeThreadId === thread.id; const isSelected = props.selectedThreadIds.has(thread.id); const isHighlighted = isActive || isSelected; @@ -369,7 +382,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { if (props.selectedThreadIds.size > 0) { props.clearSelection(); } - void props.handleThreadContextMenu(thread.id, { + void props.handleThreadContextMenu(thread, { x: event.clientX, y: event.clientY, }); @@ -678,10 +691,18 @@ function SortableProjectItem({ } export default function Sidebar() { +<<<<<<< HEAD const projectIds = useStore((store) => store.projectIds); const projectById = useStore((store) => store.projectById); const sidebarThreadsById = useStore((store) => store.sidebarThreadSummaryById); const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); +======= + const projects = useStore((store) => store.projects); + const sidebarThreadsByScopedId = useStore((store) => store.sidebarThreadsByScopedId); + const threadScopedIdsByProjectScopedId = useStore( + (store) => store.threadScopedIdsByProjectScopedId, + ); +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( useShallow((store) => ({ projectExpandedById: store.projectExpandedById, @@ -770,6 +791,27 @@ export default function Sidebar() { () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], ); +<<<<<<< HEAD +======= + const getProjectThreads = useCallback( + (project: Pick<(typeof projects)[number], "id" | "environmentId">) => + ( + threadScopedIdsByProjectScopedId[ + getProjectScopedId({ + environmentId: project.environmentId ?? null, + id: project.id, + }) + ] ?? [] + ) + .map((scopedId) => sidebarThreadsByScopedId[scopedId]) + .filter((thread): thread is NonNullable => thread !== undefined), + [sidebarThreadsByScopedId, threadScopedIdsByProjectScopedId], + ); + const sidebarThreadById = useMemo( + () => new Map(sidebarThreads.map((thread) => [thread.id, thread] as const)), + [sidebarThreads], + ); +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) const routeTerminalOpen = routeThreadId ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen : false; @@ -1029,11 +1071,15 @@ export default function Sidebar() { }, }); const handleThreadContextMenu = useCallback( - async (threadId: ThreadId, position: { x: number; y: number }) => { + async (thread: SidebarThreadSummary, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; +<<<<<<< HEAD const thread = sidebarThreadsById[threadId]; if (!thread) return; +======= + const threadId = thread.id; +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) const threadWorkspacePath = thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; const clicked = await api.contextMenu.show( @@ -1094,8 +1140,12 @@ export default function Sidebar() { copyThreadIdToClipboard, deleteThread, markThreadUnread, +<<<<<<< HEAD projectCwdById, sidebarThreadsById, +======= + projectCwdByScopedId, +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) ], ); @@ -1117,7 +1167,11 @@ export default function Sidebar() { if (clicked === "mark-unread") { for (const id of ids) { +<<<<<<< HEAD const thread = sidebarThreadsById[id]; +======= + const thread = sidebarThreadById.get(id); +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) markThreadUnread(id, thread?.latestTurn?.completedAt); } clearSelection(); @@ -1148,8 +1202,12 @@ export default function Sidebar() { deleteThread, markThreadUnread, removeFromSelection, + sidebarThreadById, selectedThreadIds, +<<<<<<< HEAD sidebarThreadsById, +======= +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) ], ); @@ -1206,11 +1264,15 @@ export default function Sidebar() { ); const handleProjectContextMenu = useCallback( - async (projectId: ProjectId, position: { x: number; y: number }) => { + async (project: Project, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; +<<<<<<< HEAD const project = projects.find((entry) => entry.id === projectId); if (!project) return; +======= + const projectId = project.id; +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) const clicked = await api.contextMenu.show( [ @@ -1264,8 +1326,12 @@ export default function Sidebar() { clearProjectDraftThreadId, copyPathToClipboard, getDraftThreadByProjectId, +<<<<<<< HEAD projects, threadIdsByProjectId, +======= + getProjectThreads, +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) ], ); @@ -1403,6 +1469,9 @@ export default function Sidebar() { hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), ); const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); + const renderedThreads = pinnedCollapsedThread + ? [pinnedCollapsedThread] + : visibleProjectThreads; const renderedThreadIds = pinnedCollapsedThread ? [pinnedCollapsedThread.id] : visibleProjectThreads.map((thread) => thread.id); @@ -1414,6 +1483,7 @@ export default function Sidebar() { orderedProjectThreadIds, project, projectStatus, + renderedThreads, renderedThreadIds, showEmptyThreadState, shouldShowThreadPanel, @@ -1559,7 +1629,7 @@ export default function Sidebar() { orderedProjectThreadIds, project, projectStatus, - renderedThreadIds, + renderedThreads, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -1581,7 +1651,7 @@ export default function Sidebar() { onContextMenu={(event) => { event.preventDefault(); suppressProjectClickForContextMenuRef.current = true; - void handleProjectContextMenu(project.id, { + void handleProjectContextMenu(project, { x: event.clientX, y: event.clientY, }); @@ -1687,16 +1757,21 @@ export default function Sidebar() { ) : null} {shouldShowThreadPanel && - renderedThreadIds.map((threadId) => ( + renderedThreads.map((thread) => ( >>>>>> 2540f09bb (Treat explicit null environments distinctly) orderedProjectThreadIds={orderedProjectThreadIds} routeThreadId={routeThreadId} selectedThreadIds={selectedThreadIds} showThreadJumpHints={showThreadJumpHints} - jumpLabel={threadJumpLabelById.get(threadId) ?? null} + jumpLabel={threadJumpLabelById.get(thread.id) ?? null} appSettingsConfirmThreadArchive={appSettings.confirmThreadArchive} renamingThreadId={renamingThreadId} renamingTitle={renamingTitle} @@ -1715,6 +1790,10 @@ export default function Sidebar() { cancelRename={cancelRename} attemptArchiveThread={attemptArchiveThread} openPrLink={openPrLink} +<<<<<<< HEAD +======= + pr={prByThreadId.get(thread.id) ?? null} +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) /> ))} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 05128905f0..ff049636aa 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -14,8 +14,14 @@ import { describe, expect, it } from "vitest"; import { applyOrchestrationEvent, applyOrchestrationEvents, +<<<<<<< HEAD selectProjects, selectThreads, +======= + getProjectScopedId, + getThreadScopedId, + selectThreadIdsByProjectId, +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) syncServerReadModel, type AppState, } from "./store"; @@ -406,6 +412,56 @@ describe("store read model sync", () => { expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); }); + + it("treats an explicit null environment as distinct from the active environment", () => { + const remoteThread = makeThread({ + id: ThreadId.makeUnsafe("thread-remote"), + projectId: ProjectId.makeUnsafe("project-remote"), + environmentId: remoteEnvironmentId, + title: "Remote thread", + }); + const initialState: AppState = { + ...makeState(remoteThread), + activeEnvironmentId: remoteEnvironmentId, + }; + + const next = syncServerReadModel( + initialState, + makeReadModel( + makeReadModelThread({ + id: ThreadId.makeUnsafe("thread-null-environment"), + title: "Null environment thread", + }), + ), + null, + ); + + expect(next.threads).toHaveLength(2); + expect(next.threads.find((thread) => thread.environmentId === remoteEnvironmentId)?.title).toBe( + "Remote thread", + ); + expect(next.threads.find((thread) => thread.environmentId === null)?.title).toBe( + "Null environment thread", + ); + }); + + it("returns a stable thread id array for unchanged project thread inputs", () => { + const projectId = ProjectId.makeUnsafe("project-1"); + const syncedState = syncServerReadModel( + makeState(makeThread()), + makeReadModel(makeReadModelThread({ projectId })), + localEnvironmentId, + ); + const selectThreadIds = selectThreadIdsByProjectId(projectId); + + const first = selectThreadIds(syncedState); + const second = selectThreadIds({ + ...syncedState, + bootstrapComplete: false, + }); + + expect(first).toBe(second); + }); }); describe("incremental orchestration updates", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 4fbb11942c..a70acfa89a 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -947,6 +947,7 @@ function buildThreadState( const turnDiffSummaryByThreadId: Record> = {}; const sidebarThreadSummaryById: Record = {}; +<<<<<<< HEAD for (const thread of threads) { threadIds.push(thread.id); threadIdsByProjectId[thread.projectId] = [ @@ -987,6 +988,13 @@ function buildThreadState( turnDiffSummaryByThreadId, sidebarThreadSummaryById, }; +======= +function resolveTargetEnvironmentId( + state: AppState, + environmentId?: EnvironmentId | null, +): EnvironmentId | null { + return environmentId !== undefined ? environmentId : (state.activeEnvironmentId ?? null); +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) } export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { @@ -1501,11 +1509,60 @@ export const selectThreadById = export const selectSidebarThreadSummaryById = (threadId: ThreadId | null | undefined) => (state: AppState): SidebarThreadSummary | undefined => +<<<<<<< HEAD threadId ? state.sidebarThreadSummaryById[threadId] : undefined; export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => (state: AppState): ThreadId[] => projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; +======= + threadId + ? state.sidebarThreadsByScopedId[ + getThreadScopedId({ + environmentId: state.activeEnvironmentId, + id: threadId, + }) + ] + : undefined; + +export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => { + let cachedProjectScopedId: string | null = null; + let cachedScopedIds: string[] | undefined; + let cachedSidebarThreadsByScopedId: Record | undefined; + let cachedResult: ThreadId[] = EMPTY_THREAD_IDS; + + return (state: AppState): ThreadId[] => { + if (!projectId) { + return EMPTY_THREAD_IDS; + } + + const projectScopedId = getProjectScopedId({ + environmentId: state.activeEnvironmentId, + id: projectId, + }); + const scopedIds = state.threadScopedIdsByProjectScopedId[projectScopedId] ?? EMPTY_SCOPED_IDS; + const sidebarThreadsByScopedId = state.sidebarThreadsByScopedId; + + if ( + cachedProjectScopedId === projectScopedId && + cachedScopedIds === scopedIds && + cachedSidebarThreadsByScopedId === sidebarThreadsByScopedId + ) { + return cachedResult; + } + + const result = scopedIds + .map((scopedId) => sidebarThreadsByScopedId[scopedId]?.id ?? null) + .filter((threadId): threadId is ThreadId => threadId !== null); + + cachedProjectScopedId = projectScopedId; + cachedScopedIds = scopedIds; + cachedSidebarThreadsByScopedId = sidebarThreadsByScopedId; + cachedResult = result; + return result; + }; +}; +>>>>>>> 2540f09bb (Treat explicit null environments distinctly) export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { return updateThreadState(state, threadId, (thread) => { From a16ee82f11adde1ee6d14521cb18d64585a9ca7c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 10:39:06 -0700 Subject: [PATCH 07/48] Support nested repo remotes and late URL resolution - Preserve GitLab subgroup paths when normalizing remotes - Re-resolve repository identity after remotes are added - Prefer the bootstrap URL when resolving the web socket server --- .../Layers/RepositoryIdentityResolver.test.ts | 43 +++++++ .../Layers/RepositoryIdentityResolver.ts | 108 ++++++++++-------- apps/web/src/lib/utils.test.ts | 24 +++- apps/web/src/lib/utils.ts | 7 +- packages/shared/src/git.test.ts | 68 +++++++---- packages/shared/src/git.ts | 2 +- 6 files changed, 170 insertions(+), 82 deletions(-) diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 8b1f4ce528..2992f17178 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -73,4 +73,47 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity?.displayName).toBe("T3Tools/t3code"); }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); + + it.effect("uses the last remote path segment as the repository name for nested groups", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-nested-group-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("gitlab.com/t3tools/platform/t3code"); + expect(identity?.displayName).toBe("t3tools/platform/t3code"); + expect(identity?.owner).toBe("t3tools"); + expect(identity?.name).toBe("t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + + it.effect("re-resolves after a remote is configured later in the same process", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-late-remote-test-", + }); + + yield* git(cwd, ["init"]); + + const resolver = yield* RepositoryIdentityResolver; + const initialIdentity = yield* resolver.resolve(cwd); + expect(initialIdentity).toBeNull(); + + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolvedIdentity = yield* resolver.resolve(cwd); + expect(resolvedIdentity).not.toBeNull(); + expect(resolvedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(resolvedIdentity?.name).toBe("t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); }); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index 9d94518afe..4e33f5c162 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -1,11 +1,8 @@ import type { RepositoryIdentity } from "@t3tools/contracts"; -import { Effect, Layer, Ref } from "effect"; -import { runProcess } from "../../processRunner.ts"; -import { - normalizeGitRemoteUrl, - parseGitHubRepositoryNameWithOwnerFromRemoteUrl, -} from "@t3tools/shared/git"; +import { Cache, Duration, Effect, Exit, Layer } from "effect"; +import { detectGitHostingProviderFromRemoteUrl, normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { runProcess } from "../../processRunner.ts"; import { RepositoryIdentityResolver, type RepositoryIdentityResolverShape, @@ -47,8 +44,11 @@ function buildRepositoryIdentity(input: { readonly remoteUrl: string; }): RepositoryIdentity { const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); - const githubNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(input.remoteUrl); - const [owner, repositoryName] = githubNameWithOwner?.split("/") ?? []; + const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl); + const repositoryPath = canonicalKey.split("/").slice(1).join("/"); + const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); + const [owner] = repositoryPathSegments; + const repositoryName = repositoryPathSegments.at(-1); return { canonicalKey, @@ -57,81 +57,91 @@ function buildRepositoryIdentity(input: { remoteName: input.remoteName, remoteUrl: input.remoteUrl, }, - ...(githubNameWithOwner ? { displayName: githubNameWithOwner } : {}), - ...(githubNameWithOwner ? { provider: "github" } : {}), + ...(repositoryPath ? { displayName: repositoryPath } : {}), + ...(hostingProvider ? { provider: hostingProvider.kind } : {}), ...(owner ? { owner } : {}), ...(repositoryName ? { name: repositoryName } : {}), }; } -async function resolveRepositoryIdentity(cwd: string): Promise<{ - readonly cacheKey: string; - readonly identity: RepositoryIdentity | null; -}> { - let topLevel = cwd; +const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; +const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); +const DEFAULT_NEGATIVE_CACHE_TTL = Duration.seconds(10); + +interface RepositoryIdentityResolverOptions { + readonly cacheCapacity?: number; + readonly positiveCacheTtl?: Duration.Input; + readonly negativeCacheTtl?: Duration.Input; +} + +async function resolveRepositoryIdentityCacheKey(cwd: string): Promise { + let cacheKey = cwd; try { const topLevelResult = await runProcess("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { allowNonZeroExit: true, }); if (topLevelResult.code !== 0) { - return { cacheKey: cwd, identity: null }; + return cacheKey; } const candidate = topLevelResult.stdout.trim(); if (candidate.length > 0) { - topLevel = candidate; + cacheKey = candidate; } } catch { - return { cacheKey: cwd, identity: null }; + return cacheKey; } + return cacheKey; +} + +async function resolveRepositoryIdentityFromCacheKey( + cacheKey: string, +): Promise { try { - const remoteResult = await runProcess("git", ["-C", topLevel, "remote", "-v"], { + const remoteResult = await runProcess("git", ["-C", cacheKey, "remote", "-v"], { allowNonZeroExit: true, }); if (remoteResult.code !== 0) { - return { cacheKey: topLevel, identity: null }; + return null; } const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); - return { - cacheKey: topLevel, - identity: remote ? buildRepositoryIdentity(remote) : null, - }; + return remote ? buildRepositoryIdentity(remote) : null; } catch { - return { cacheKey: topLevel, identity: null }; + return null; } } -export const makeRepositoryIdentityResolver = Effect.gen(function* () { - const cacheRef = yield* Ref.make(new Map()); - - const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( - "RepositoryIdentityResolver.resolve", - )(function* (cwd) { - const cache = yield* Ref.get(cacheRef); - const cached = cache.get(cwd); - if (cached !== undefined) { - return cached; - } +export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")( + function* (options: RepositoryIdentityResolverOptions = {}) { + const repositoryIdentityCache = yield* Cache.makeWith({ + capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, + lookup: (cacheKey) => Effect.promise(() => resolveRepositoryIdentityFromCacheKey(cacheKey)), + timeToLive: Exit.match({ + onSuccess: (value) => + value === null + ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) + : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + onFailure: () => Duration.zero, + }), + }); - const resolved = yield* Effect.promise(() => resolveRepositoryIdentity(cwd)); - yield* Ref.update(cacheRef, (current) => { - const next = new Map(current); - next.set(cwd, resolved.identity); - next.set(resolved.cacheKey, resolved.identity); - return next; + const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( + "RepositoryIdentityResolver.resolve", + )(function* (cwd) { + const cacheKey = yield* Effect.promise(() => resolveRepositoryIdentityCacheKey(cwd)); + return yield* Cache.get(repositoryIdentityCache, cacheKey); }); - return resolved.identity; - }); - return { - resolve, - } satisfies RepositoryIdentityResolverShape; -}); + return { + resolve, + } satisfies RepositoryIdentityResolverShape; + }, +); export const RepositoryIdentityResolverLive = Layer.effect( RepositoryIdentityResolver, - makeRepositoryIdentityResolver, + makeRepositoryIdentityResolver(), ); diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts index 017b6bee07..92cb092392 100644 --- a/apps/web/src/lib/utils.test.ts +++ b/apps/web/src/lib/utils.test.ts @@ -1,6 +1,11 @@ -import { assert, describe, it } from "vitest"; +import { assert, describe, expect, it, vi } from "vitest"; + +vi.mock("../environmentBootstrap", () => ({ + resolvePrimaryEnvironmentBootstrapUrl: vi.fn(() => "http://bootstrap.test:4321"), +})); import { isWindowsPlatform } from "./utils"; +import { resolveServerUrl } from "./utils"; describe("isWindowsPlatform", () => { it("matches Windows platform identifiers", () => { @@ -13,3 +18,20 @@ describe("isWindowsPlatform", () => { assert.isFalse(isWindowsPlatform("darwin")); }); }); + +describe("resolveServerUrl", () => { + it("uses the bootstrap environment URL when no explicit URL is provided", () => { + expect(resolveServerUrl()).toBe("http://bootstrap.test:4321/"); + }); + + it("prefers an explicit URL override", () => { + expect( + resolveServerUrl({ + url: "https://override.test:9999", + protocol: "wss", + pathname: "/rpc", + searchParams: { hello: "world" }, + }), + ).toBe("wss://override.test:9999/rpc?hello=world"); + }); +}); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 5b0bcec4bd..7c3b5e4590 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -53,12 +53,7 @@ export const resolveServerUrl = (options?: { pathname?: string | undefined; searchParams?: Record | undefined; }): string => { - const rawUrl = firstNonEmptyString( - options?.url, - resolvePrimaryEnvironmentBootstrapUrl(), - import.meta.env.VITE_WS_URL, - window.location.origin, - ); + const rawUrl = firstNonEmptyString(options?.url, resolvePrimaryEnvironmentBootstrapUrl()); const parsedUrl = new URL(rawUrl); if (options?.protocol) { diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 154acb0957..dac644e83b 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -7,6 +7,49 @@ import { parseGitHubRepositoryNameWithOwnerFromRemoteUrl, } from "./git"; +describe("normalizeGitRemoteUrl", () => { + it("canonicalizes equivalent GitHub remotes across protocol variants", () => { + expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("https://github.com/T3Tools/T3Code.git")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("ssh://git@github.com/T3Tools/T3Code")).toBe( + "github.com/t3tools/t3code", + ); + }); + + it("preserves nested group paths for providers like GitLab", () => { + expect(normalizeGitRemoteUrl("git@gitlab.com:T3Tools/platform/T3Code.git")).toBe( + "gitlab.com/t3tools/platform/t3code", + ); + expect(normalizeGitRemoteUrl("https://gitlab.com/T3Tools/platform/T3Code.git")).toBe( + "gitlab.com/t3tools/platform/t3code", + ); + }); + + it("drops explicit ports from URL-shaped remotes", () => { + expect(normalizeGitRemoteUrl("https://gitlab.company.com:8443/team/project.git")).toBe( + "gitlab.company.com/team/project", + ); + expect(normalizeGitRemoteUrl("ssh://git@gitlab.company.com:2222/team/project.git")).toBe( + "gitlab.company.com/team/project", + ); + }); +}); + +describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { + it("extracts the owner and repository from common GitHub remote shapes", () => { + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:T3Tools/T3Code.git"), + ).toBe("T3Tools/T3Code"); + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"), + ).toBe("T3Tools/T3Code"); + }); +}); + describe("applyGitStatusStreamEvent", () => { it("treats a remote-only update as a repository when local state is missing", () => { const remote: GitStatusRemoteResult = { @@ -69,28 +112,3 @@ describe("applyGitStatusStreamEvent", () => { }); }); }); - -describe("normalizeGitRemoteUrl", () => { - it("canonicalizes equivalent GitHub remotes across protocol variants", () => { - expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe( - "github.com/t3tools/t3code", - ); - expect(normalizeGitRemoteUrl("https://github.com/T3Tools/T3Code.git")).toBe( - "github.com/t3tools/t3code", - ); - expect(normalizeGitRemoteUrl("ssh://git@github.com/T3Tools/T3Code")).toBe( - "github.com/t3tools/t3code", - ); - }); -}); - -describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { - it("extracts the owner and repository from common GitHub remote shapes", () => { - expect( - parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:T3Tools/T3Code.git"), - ).toBe("T3Tools/T3Code"); - expect( - parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"), - ).toBe("T3Tools/T3Code"); - }); -}); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 167be1e03d..55871f8ed5 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -90,7 +90,7 @@ export function normalizeGitRemoteUrl(value: string): string { .replace(/\.git$/i, "") .toLowerCase(); const hostAndPath = - /^(?:git@|ssh:\/\/git@|https:\/\/|http:\/\/|git:\/\/)([^/:]+)[:/]([^/\s]+\/[^/\s]+)$/i.exec( + /^(?:git@|ssh:\/\/git@|https:\/\/|http:\/\/|git:\/\/)([^/:]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec( normalized, ); From 40ab9d9295a568a923bcc9be919af633f08eeddf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 11:25:39 -0700 Subject: [PATCH 08/48] Require environment IDs in web state sync - Make active environment mandatory in store and read-model sync - Scope chat, sidebar, and event replay state by resolved environment - Update tests for environment-aware thread and project handling --- .../web/src/components/ChatView.logic.test.ts | 8 +- apps/web/src/components/ChatView.logic.ts | 10 +- apps/web/src/components/ChatView.tsx | 12 +- apps/web/src/components/Sidebar.logic.test.ts | 6 +- apps/web/src/components/Sidebar.tsx | 105 +------ apps/web/src/routes/__root.tsx | 84 ++++- apps/web/src/store.test.ts | 98 +++--- apps/web/src/store.ts | 294 ++++++++++++------ apps/web/src/types.ts | 7 +- apps/web/src/worktreeCleanup.test.ts | 5 +- 10 files changed, 361 insertions(+), 268 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index cad565247d..03bc9ed9f5 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,4 +1,4 @@ -import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useStore } from "../store"; import { type Thread } from "../types"; @@ -13,6 +13,8 @@ import { waitForStartedServerThread, } from "./ChatView.logic"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ @@ -181,6 +183,7 @@ const makeThread = (input?: { } | null; }): Thread => ({ id: input?.id ?? ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", @@ -414,6 +417,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { it("does not clear local dispatch before server state changes", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId, title: "Thread", @@ -450,6 +454,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { it("clears local dispatch when a new turn is already settled", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId, title: "Thread", @@ -495,6 +500,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { it("clears local dispatch when the session changes without an observed running phase", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId, title: "Thread", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 6a0aa4d0c8..3818613fbe 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,10 @@ -import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts"; +import { + type EnvironmentId, + ProjectId, + type ModelSelection, + type ThreadId, + type TurnId, +} from "@t3tools/contracts"; import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; @@ -18,12 +24,14 @@ export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema. export function buildLocalDraftThread( threadId: ThreadId, + environmentId: EnvironmentId, draftThread: DraftThreadState, fallbackModelSelection: ModelSelection, error: string | null, ): Thread { return { id: threadId, + environmentId, codexThreadId: null, projectId: draftThread.projectId, title: "New thread", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index da5c87cbfc..f6bdcf1117 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -775,12 +775,14 @@ export default function ChatView({ threadId }: ChatViewProps) { const fallbackDraftProject = useStore((state) => draftThread?.projectId ? state.projectById[draftThread.projectId] : undefined, ); + const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => - draftThread + draftThread && activeEnvironmentId ? buildLocalDraftThread( threadId, + activeEnvironmentId, draftThread, fallbackDraftProject?.defaultModelSelection ?? { provider: "codex", @@ -789,7 +791,13 @@ export default function ChatView({ threadId }: ChatViewProps) { localDraftError, ) : undefined, - [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], + [ + activeEnvironmentId, + draftThread, + fallbackDraftProject?.defaultModelSelection, + localDraftError, + threadId, + ], ); const activeThread = serverThread ?? localDraftThread; const runtimeMode = diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a4f4468279..f9e5561a50 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -20,7 +20,7 @@ import { sortThreadsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; -import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -28,6 +28,8 @@ import { type Thread, } from "../types"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + function makeLatestTurn(overrides?: { completedAt?: string | null; startedAt?: string | null; @@ -625,6 +627,7 @@ function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { id: ProjectId.makeUnsafe("project-1"), + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -642,6 +645,7 @@ function makeProject(overrides: Partial = {}): Project { function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9bc21cf8b3..923d9d88e8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -58,11 +58,7 @@ 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"; -<<<<<<< HEAD import { useStore } from "../store"; -======= -import { getProjectScopedId, useStore } from "../store"; ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { @@ -132,11 +128,7 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -<<<<<<< HEAD import type { Project } from "../types"; -======= -import type { Project, SidebarThreadSummary } from "../types"; ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -263,12 +255,8 @@ function resolveThreadPr( } interface SidebarThreadRowProps { -<<<<<<< HEAD threadId: ThreadId; projectCwd: string | null; -======= - thread: SidebarThreadSummary; ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) orderedProjectThreadIds: readonly ThreadId[]; routeThreadId: ThreadId | null; selectedThreadIds: ReadonlySet; @@ -291,7 +279,7 @@ interface SidebarThreadRowProps { navigateToThread: (threadId: ThreadId) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( - thread: SidebarThreadSummary, + threadId: ThreadId, position: { x: number; y: number }, ) => Promise; clearSelection: () => void; @@ -302,20 +290,19 @@ interface SidebarThreadRowProps { } function SidebarThreadRow(props: SidebarThreadRowProps) { -<<<<<<< HEAD const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); -======= - const { thread } = props; - const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[thread.id]); ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) const runningTerminalIds = useTerminalStateStore( (state) => - selectThreadTerminalState(state.terminalStateByThreadId, thread.id).runningTerminalIds, + selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, ); const gitCwd = thread?.worktreePath ?? props.projectCwd; const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null); + if (!thread) { + return null; + } + const isActive = props.routeThreadId === thread.id; const isSelected = props.selectedThreadIds.has(thread.id); const isHighlighted = isActive || isSelected; @@ -382,7 +369,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { if (props.selectedThreadIds.size > 0) { props.clearSelection(); } - void props.handleThreadContextMenu(thread, { + void props.handleThreadContextMenu(thread.id, { x: event.clientX, y: event.clientY, }); @@ -691,18 +678,10 @@ function SortableProjectItem({ } export default function Sidebar() { -<<<<<<< HEAD const projectIds = useStore((store) => store.projectIds); const projectById = useStore((store) => store.projectById); const sidebarThreadsById = useStore((store) => store.sidebarThreadSummaryById); const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); -======= - const projects = useStore((store) => store.projects); - const sidebarThreadsByScopedId = useStore((store) => store.sidebarThreadsByScopedId); - const threadScopedIdsByProjectScopedId = useStore( - (store) => store.threadScopedIdsByProjectScopedId, - ); ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( useShallow((store) => ({ projectExpandedById: store.projectExpandedById, @@ -791,27 +770,6 @@ export default function Sidebar() { () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], ); -<<<<<<< HEAD -======= - const getProjectThreads = useCallback( - (project: Pick<(typeof projects)[number], "id" | "environmentId">) => - ( - threadScopedIdsByProjectScopedId[ - getProjectScopedId({ - environmentId: project.environmentId ?? null, - id: project.id, - }) - ] ?? [] - ) - .map((scopedId) => sidebarThreadsByScopedId[scopedId]) - .filter((thread): thread is NonNullable => thread !== undefined), - [sidebarThreadsByScopedId, threadScopedIdsByProjectScopedId], - ); - const sidebarThreadById = useMemo( - () => new Map(sidebarThreads.map((thread) => [thread.id, thread] as const)), - [sidebarThreads], - ); ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) const routeTerminalOpen = routeThreadId ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen : false; @@ -1071,15 +1029,11 @@ export default function Sidebar() { }, }); const handleThreadContextMenu = useCallback( - async (thread: SidebarThreadSummary, position: { x: number; y: number }) => { + async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; -<<<<<<< HEAD const thread = sidebarThreadsById[threadId]; if (!thread) return; -======= - const threadId = thread.id; ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) const threadWorkspacePath = thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; const clicked = await api.contextMenu.show( @@ -1140,12 +1094,8 @@ export default function Sidebar() { copyThreadIdToClipboard, deleteThread, markThreadUnread, -<<<<<<< HEAD projectCwdById, sidebarThreadsById, -======= - projectCwdByScopedId, ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) ], ); @@ -1167,11 +1117,7 @@ export default function Sidebar() { if (clicked === "mark-unread") { for (const id of ids) { -<<<<<<< HEAD const thread = sidebarThreadsById[id]; -======= - const thread = sidebarThreadById.get(id); ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) markThreadUnread(id, thread?.latestTurn?.completedAt); } clearSelection(); @@ -1202,12 +1148,8 @@ export default function Sidebar() { deleteThread, markThreadUnread, removeFromSelection, - sidebarThreadById, selectedThreadIds, -<<<<<<< HEAD sidebarThreadsById, -======= ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) ], ); @@ -1264,15 +1206,11 @@ export default function Sidebar() { ); const handleProjectContextMenu = useCallback( - async (project: Project, position: { x: number; y: number }) => { + async (projectId: ProjectId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; -<<<<<<< HEAD const project = projects.find((entry) => entry.id === projectId); if (!project) return; -======= - const projectId = project.id; ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) const clicked = await api.contextMenu.show( [ @@ -1326,12 +1264,8 @@ export default function Sidebar() { clearProjectDraftThreadId, copyPathToClipboard, getDraftThreadByProjectId, -<<<<<<< HEAD projects, threadIdsByProjectId, -======= - getProjectThreads, ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) ], ); @@ -1469,9 +1403,6 @@ export default function Sidebar() { hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), ); const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - const renderedThreads = pinnedCollapsedThread - ? [pinnedCollapsedThread] - : visibleProjectThreads; const renderedThreadIds = pinnedCollapsedThread ? [pinnedCollapsedThread.id] : visibleProjectThreads.map((thread) => thread.id); @@ -1483,7 +1414,6 @@ export default function Sidebar() { orderedProjectThreadIds, project, projectStatus, - renderedThreads, renderedThreadIds, showEmptyThreadState, shouldShowThreadPanel, @@ -1629,7 +1559,7 @@ export default function Sidebar() { orderedProjectThreadIds, project, projectStatus, - renderedThreads, + renderedThreadIds, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -1651,7 +1581,7 @@ export default function Sidebar() { onContextMenu={(event) => { event.preventDefault(); suppressProjectClickForContextMenuRef.current = true; - void handleProjectContextMenu(project, { + void handleProjectContextMenu(project.id, { x: event.clientX, y: event.clientY, }); @@ -1757,21 +1687,16 @@ export default function Sidebar() { ) : null} {shouldShowThreadPanel && - renderedThreads.map((thread) => ( + renderedThreadIds.map((threadId) => ( >>>>>> 2540f09bb (Treat explicit null environments distinctly) orderedProjectThreadIds={orderedProjectThreadIds} routeThreadId={routeThreadId} selectedThreadIds={selectedThreadIds} showThreadJumpHints={showThreadJumpHints} - jumpLabel={threadJumpLabelById.get(thread.id) ?? null} + jumpLabel={threadJumpLabelById.get(threadId) ?? null} appSettingsConfirmThreadArchive={appSettings.confirmThreadArchive} renamingThreadId={renamingThreadId} renamingTitle={renamingTitle} @@ -1790,10 +1715,6 @@ export default function Sidebar() { cancelRename={cancelRename} attemptArchiveThread={attemptArchiveThread} openPrLink={openPrLink} -<<<<<<< HEAD -======= - pr={prByThreadId.get(thread.id) ?? null} ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) /> ))} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 48c835ae79..4cee742092 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,5 @@ import { + type EnvironmentId, OrchestrationEvent, type ServerLifecycleWelcomePayload, ThreadId, @@ -201,6 +202,13 @@ function coalesceOrchestrationUiEvents( const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; const MAX_NO_PROGRESS_REPLAY_RETRIES = 3; +function resolveKnownEnvironmentId(input: { + serverConfigEnvironmentId: EnvironmentId | null | undefined; + activeEnvironmentId: EnvironmentId | null; +}): EnvironmentId | null { + return input.serverConfigEnvironmentId ?? input.activeEnvironmentId; +} + function ServerStateBootstrap() { useEffect(() => startServerStateSync(getWsRpcClient().server), []); @@ -209,6 +217,7 @@ function ServerStateBootstrap() { function EventRouter() { const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); + const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); const syncProjects = useUiStateStore((store) => store.syncProjects); @@ -226,15 +235,26 @@ function EventRouter() { const handledBootstrapThreadIdRef = useRef(null); const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); const disposedRef = useRef(false); - const bootstrapFromSnapshotRef = useRef<() => Promise>(async () => undefined); + const bootstrapFromSnapshotRef = useRef<(environmentId: EnvironmentId) => Promise>( + async () => undefined, + ); + const schedulePendingDomainEventFlushRef = useRef<() => void>(() => undefined); const serverConfig = useServerConfig(); + const resolveCurrentEnvironmentId = useEffectEvent((): EnvironmentId | null => + resolveKnownEnvironmentId({ + serverConfigEnvironmentId: serverConfig?.environment.environmentId, + activeEnvironmentId: useStore.getState().activeEnvironmentId, + }), + ); const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { if (!payload) return; + setActiveEnvironmentId(payload.environment.environmentId); + schedulePendingDomainEventFlushRef.current(); migrateLocalSettingsToServer(); void (async () => { - await bootstrapFromSnapshotRef.current(); + await bootstrapFromSnapshotRef.current(payload.environment.environmentId); if (disposedRef.current) { return; } @@ -316,6 +336,15 @@ function EventRouter() { }, ); + useEffect(() => { + if (!serverConfig) { + return; + } + + setActiveEnvironmentId(serverConfig.environment.environmentId); + schedulePendingDomainEventFlushRef.current(); + }, [serverConfig, setActiveEnvironmentId]); + useEffect(() => { const api = readNativeApi(); if (!api) return; @@ -371,7 +400,10 @@ function EventRouter() { }, ); - const applyEventBatch = (events: ReadonlyArray) => { + const applyEventBatch = ( + events: ReadonlyArray, + environmentId: EnvironmentId, + ) => { const nextEvents = recovery.markEventBatchApplied(events); if (nextEvents.length === 0) { return; @@ -391,7 +423,7 @@ function EventRouter() { void queryInvalidationThrottler.maybeExecute(); } - applyOrchestrationEvents(uiEvents); + applyOrchestrationEvents(uiEvents, environmentId); if (needsProjectUiSync) { const projects = selectProjects(useStore.getState()); syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); @@ -425,9 +457,13 @@ function EventRouter() { if (disposed || pendingDomainEvents.length === 0) { return; } + const currentEnvironmentId = resolveCurrentEnvironmentId(); + if (currentEnvironmentId === null) { + return; + } const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); - applyEventBatch(events); + applyEventBatch(events, currentEnvironmentId); }; const schedulePendingDomainEventFlush = () => { if (flushPendingDomainEventsScheduled) { @@ -437,6 +473,7 @@ function EventRouter() { flushPendingDomainEventsScheduled = true; queueMicrotask(flushPendingDomainEvents); }; + schedulePendingDomainEventFlushRef.current = schedulePendingDomainEventFlush; const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise => { if (!recovery.beginReplayRecovery(reason)) { @@ -447,7 +484,13 @@ function EventRouter() { try { const events = await api.orchestration.replayEvents(fromSequenceExclusive); if (!disposed) { - applyEventBatch(events); + const currentEnvironmentId = resolveCurrentEnvironmentId(); + if (currentEnvironmentId === null) { + replayRetryTracker = null; + recovery.failReplayRecovery(); + return; + } + applyEventBatch(events, currentEnvironmentId); } } catch { replayRetryTracker = null; @@ -489,7 +532,10 @@ function EventRouter() { } }; - const runSnapshotRecovery = async (reason: "bootstrap" | "replay-failed"): Promise => { + const runSnapshotRecovery = async ( + reason: "bootstrap" | "replay-failed", + environmentId: EnvironmentId, + ): Promise => { const started = recovery.beginSnapshotRecovery(reason); if (import.meta.env.MODE !== "test") { const state = recovery.getState(); @@ -512,7 +558,7 @@ function EventRouter() { try { const snapshot = await api.orchestration.getSnapshot(); if (!disposed) { - syncServerReadModel(snapshot); + syncServerReadModel(snapshot, environmentId); reconcileSnapshotDerivedState(); if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { void runReplayRecovery("sequence-gap"); @@ -524,13 +570,17 @@ function EventRouter() { } }; - const bootstrapFromSnapshot = async (): Promise => { - await runSnapshotRecovery("bootstrap"); + const bootstrapFromSnapshot = async (environmentId: EnvironmentId): Promise => { + await runSnapshotRecovery("bootstrap", environmentId); }; bootstrapFromSnapshotRef.current = bootstrapFromSnapshot; const fallbackToSnapshotRecovery = async (): Promise => { - await runSnapshotRecovery("replay-failed"); + const currentEnvironmentId = resolveCurrentEnvironmentId(); + if (currentEnvironmentId === null) { + return; + } + await runSnapshotRecovery("replay-failed", currentEnvironmentId); }; const unsubDomainEvent = api.orchestration.onDomainEvent( (event) => { @@ -556,8 +606,16 @@ function EventRouter() { }, ); const unsubTerminalEvent = api.terminal.onEvent((event) => { + const currentEnvironmentId = resolveCurrentEnvironmentId(); + if (currentEnvironmentId === null) { + return; + } const thread = selectThreadById(ThreadId.makeUnsafe(event.threadId))(useStore.getState()); - if (thread && thread.archivedAt !== null) { + if ( + thread && + thread.environmentId === currentEnvironmentId && + thread.archivedAt !== null + ) { return; } applyTerminalEvent(event); @@ -568,6 +626,7 @@ function EventRouter() { needsProviderInvalidation = false; flushPendingDomainEventsScheduled = false; pendingDomainEvents.length = 0; + schedulePendingDomainEventFlushRef.current = () => undefined; queryInvalidationThrottler.cancel(); unsubDomainEvent(); unsubTerminalEvent(); @@ -581,6 +640,7 @@ function EventRouter() { applyTerminalEvent, clearThreadUi, setProjectExpanded, + setActiveEnvironmentId, syncProjects, syncServerReadModel, syncThreads, diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index ff049636aa..2fef45fed6 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,6 +1,7 @@ import { CheckpointRef, DEFAULT_MODEL_BY_PROVIDER, + EnvironmentId, EventId, MessageId, ProjectId, @@ -14,22 +15,48 @@ import { describe, expect, it } from "vitest"; import { applyOrchestrationEvent, applyOrchestrationEvents, -<<<<<<< HEAD selectProjects, selectThreads, -======= - getProjectScopedId, - getThreadScopedId, - selectThreadIdsByProjectId, ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) syncServerReadModel, type AppState, } from "./store"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + +function withActiveEnvironmentState( + environmentState: Omit, + overrides: Partial = {}, +): AppState { + const { + activeEnvironmentId: overrideActiveEnvironmentId, + environmentStateById: overrideEnvironmentStateById, + ...environmentOverrides + } = overrides; + const activeEnvironmentId = overrideActiveEnvironmentId ?? localEnvironmentId; + const mergedEnvironmentState = { + ...environmentState, + ...environmentOverrides, + }; + const environmentStateById = + overrideEnvironmentStateById ?? + (activeEnvironmentId + ? { + [activeEnvironmentId]: mergedEnvironmentState, + } + : {}); + + return { + activeEnvironmentId, + environmentStateById, + ...mergedEnvironmentState, + }; +} + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", @@ -58,6 +85,7 @@ function makeState(thread: Thread): AppState { const projectId = ProjectId.makeUnsafe("project-1"); const project = { id: projectId, + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -71,7 +99,7 @@ function makeState(thread: Thread): AppState { const threadIdsByProjectId: AppState["threadIdsByProjectId"] = { [thread.projectId]: [thread.id], }; - return { + const environmentState = { projectIds: [projectId], projectById: { [projectId]: project, @@ -81,6 +109,7 @@ function makeState(thread: Thread): AppState { threadShellById: { [thread.id]: { id: thread.id, + environmentId: thread.environmentId, codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, @@ -141,10 +170,11 @@ function makeState(thread: Thread): AppState { sidebarThreadSummaryById: {}, bootstrapComplete: true, }; + return withActiveEnvironmentState(environmentState); } function makeEmptyState(overrides: Partial = {}): AppState { - return { + const environmentState = { projectIds: [], projectById: {}, threadIds: [], @@ -162,8 +192,8 @@ function makeEmptyState(overrides: Partial = {}): AppState { turnDiffSummaryByThreadId: {}, sidebarThreadSummaryById: {}, bootstrapComplete: true, - ...overrides, }; + return withActiveEnvironmentState(environmentState, overrides); } function projectsOf(state: AppState) { @@ -412,56 +442,6 @@ describe("store read model sync", () => { expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); }); - - it("treats an explicit null environment as distinct from the active environment", () => { - const remoteThread = makeThread({ - id: ThreadId.makeUnsafe("thread-remote"), - projectId: ProjectId.makeUnsafe("project-remote"), - environmentId: remoteEnvironmentId, - title: "Remote thread", - }); - const initialState: AppState = { - ...makeState(remoteThread), - activeEnvironmentId: remoteEnvironmentId, - }; - - const next = syncServerReadModel( - initialState, - makeReadModel( - makeReadModelThread({ - id: ThreadId.makeUnsafe("thread-null-environment"), - title: "Null environment thread", - }), - ), - null, - ); - - expect(next.threads).toHaveLength(2); - expect(next.threads.find((thread) => thread.environmentId === remoteEnvironmentId)?.title).toBe( - "Remote thread", - ); - expect(next.threads.find((thread) => thread.environmentId === null)?.title).toBe( - "Null environment thread", - ); - }); - - it("returns a stable thread id array for unchanged project thread inputs", () => { - const projectId = ProjectId.makeUnsafe("project-1"); - const syncedState = syncServerReadModel( - makeState(makeThread()), - makeReadModel(makeReadModelThread({ projectId })), - localEnvironmentId, - ); - const selectThreadIds = selectThreadIdsByProjectId(projectId); - - const first = selectThreadIds(syncedState); - const second = selectThreadIds({ - ...syncedState, - bootstrapComplete: false, - }); - - expect(first).toBe(second); - }); }); describe("incremental orchestration updates", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index a70acfa89a..54429f9c15 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,4 +1,5 @@ import { + type EnvironmentId, type MessageId, type OrchestrationCheckpointSummary, type OrchestrationEvent, @@ -35,7 +36,7 @@ import { } from "./types"; import { sanitizeThreadErrorMessage } from "./rpc/transportError"; -export interface AppState { +export interface EnvironmentState { projectIds: ProjectId[]; projectById: Record; threadIds: ThreadId[]; @@ -55,7 +56,12 @@ export interface AppState { bootstrapComplete: boolean; } -const initialState: AppState = { +export interface AppState extends EnvironmentState { + activeEnvironmentId: EnvironmentId | null; + environmentStateById: Record; +} + +const initialEnvironmentState: EnvironmentState = { projectIds: [], projectById: {}, threadIds: [], @@ -75,6 +81,12 @@ const initialState: AppState = { bootstrapComplete: false, }; +const initialState: AppState = { + activeEnvironmentId: null, + environmentStateById: {}, + ...initialEnvironmentState, +}; + const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; const MAX_THREAD_PROPOSED_PLANS = 200; @@ -169,9 +181,13 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif }; } -function mapProject(project: OrchestrationReadModel["projects"][number]): Project { +function mapProject( + project: OrchestrationReadModel["projects"][number], + environmentId: EnvironmentId, +): Project { return { id: project.id, + environmentId, name: project.title, cwd: project.workspaceRoot, defaultModelSelection: project.defaultModelSelection @@ -183,9 +199,10 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec }; } -function mapThread(thread: OrchestrationThread): Thread { +function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): Thread { return { id: thread.id, + environmentId, codexThreadId: null, projectId: thread.projectId, title: thread.title, @@ -211,6 +228,7 @@ function mapThread(thread: OrchestrationThread): Thread { function toThreadShell(thread: Thread): ThreadShell { return { id: thread.id, + environmentId: thread.environmentId, codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, @@ -251,6 +269,7 @@ function getLatestUserMessageAt(messages: ReadonlyArray): string | function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary { return { id: thread.id, + environmentId: thread.environmentId, projectId: thread.projectId, title: thread.title, interactionMode: thread.interactionMode, @@ -298,6 +317,7 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b return ( left !== undefined && left.id === right.id && + left.environmentId === right.environmentId && left.codexThreadId === right.codexThreadId && left.projectId === right.projectId && left.title === right.title && @@ -377,7 +397,7 @@ function buildTurnDiffSlice(thread: Thread): { }; } -function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[] { +function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): ChatMessage[] { const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS; const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP; if (ids.length === 0) { @@ -390,7 +410,7 @@ function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[ } function selectThreadActivities( - state: AppState, + state: EnvironmentState, threadId: ThreadId, ): OrchestrationThreadActivity[] { const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS; @@ -404,7 +424,7 @@ function selectThreadActivities( }); } -function selectThreadProposedPlans(state: AppState, threadId: ThreadId): ProposedPlan[] { +function selectThreadProposedPlans(state: EnvironmentState, threadId: ThreadId): ProposedPlan[] { const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS; const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP; if (ids.length === 0) { @@ -416,7 +436,10 @@ function selectThreadProposedPlans(state: AppState, threadId: ThreadId): Propose }); } -function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): TurnDiffSummary[] { +function selectThreadTurnDiffSummaries( + state: EnvironmentState, + threadId: ThreadId, +): TurnDiffSummary[] { const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS; const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP; if (ids.length === 0) { @@ -428,7 +451,7 @@ function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): Tur }); } -function getThread(state: AppState, threadId: ThreadId): Thread | undefined { +function getThread(state: EnvironmentState, threadId: ThreadId): Thread | undefined { const shell = state.threadShellById[threadId]; if (!shell) { return undefined; @@ -446,21 +469,25 @@ function getThread(state: AppState, threadId: ThreadId): Thread | undefined { }; } -function getProjects(state: AppState): Project[] { +function getProjects(state: EnvironmentState): Project[] { return state.projectIds.flatMap((projectId) => { const project = state.projectById[projectId]; return project ? [project] : []; }); } -function getThreads(state: AppState): Thread[] { +function getThreads(state: EnvironmentState): Thread[] { return state.threadIds.flatMap((threadId) => { const thread = getThread(state, threadId); return thread ? [thread] : []; }); } -function writeThreadState(state: AppState, nextThread: Thread, previousThread?: Thread): AppState { +function writeThreadState( + state: EnvironmentState, + nextThread: Thread, + previousThread?: Thread, +): EnvironmentState { const nextShell = toThreadShell(nextThread); const nextTurnState = toThreadTurnState(nextThread); const previousShell = state.threadShellById[nextThread.id]; @@ -613,7 +640,7 @@ function writeThreadState(state: AppState, nextThread: Thread, previousThread?: return nextState; } -function removeThreadState(state: AppState, threadId: ThreadId): AppState { +function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState { const shell = state.threadShellById[threadId]; if (!shell) { return state; @@ -887,10 +914,10 @@ function attachmentPreviewRoutePath(attachmentId: string): string { } function updateThreadState( - state: AppState, + state: EnvironmentState, threadId: ThreadId, updater: (thread: Thread) => Thread, -): AppState { +): EnvironmentState { const currentThread = getThread(state, threadId); if (!currentThread) { return state; @@ -904,7 +931,7 @@ function updateThreadState( function buildProjectState( projects: ReadonlyArray, -): Pick { +): Pick { return { projectIds: projects.map((project) => project.id), projectById: Object.fromEntries( @@ -916,7 +943,7 @@ function buildProjectState( function buildThreadState( threads: ReadonlyArray, ): Pick< - AppState, + EnvironmentState, | "threadIds" | "threadIdsByProjectId" | "threadShellById" @@ -947,7 +974,6 @@ function buildThreadState( const turnDiffSummaryByThreadId: Record> = {}; const sidebarThreadSummaryById: Record = {}; -<<<<<<< HEAD for (const thread of threads) { threadIds.push(thread.id); threadIdsByProjectId[thread.projectId] = [ @@ -988,20 +1014,66 @@ function buildThreadState( turnDiffSummaryByThreadId, sidebarThreadSummaryById, }; -======= -function resolveTargetEnvironmentId( +} + +function getStoredEnvironmentState( + state: AppState, + environmentId: EnvironmentId, +): EnvironmentState { + return state.environmentStateById[environmentId] ?? initialEnvironmentState; +} + +function projectActiveEnvironmentState(input: { + activeEnvironmentId: EnvironmentId | null; + environmentStateById: Record; +}): AppState { + const projectedState = + input.activeEnvironmentId === null + ? initialEnvironmentState + : (input.environmentStateById[input.activeEnvironmentId] ?? initialEnvironmentState); + + return { + activeEnvironmentId: input.activeEnvironmentId, + environmentStateById: input.environmentStateById, + ...projectedState, + }; +} + +function commitEnvironmentState( state: AppState, - environmentId?: EnvironmentId | null, -): EnvironmentId | null { - return environmentId !== undefined ? environmentId : (state.activeEnvironmentId ?? null); ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) + environmentId: EnvironmentId, + nextEnvironmentState: EnvironmentState, +): AppState { + const currentEnvironmentState = state.environmentStateById[environmentId]; + const environmentStateById = + currentEnvironmentState === nextEnvironmentState + ? state.environmentStateById + : { + ...state.environmentStateById, + [environmentId]: nextEnvironmentState, + }; + + if (environmentStateById === state.environmentStateById) { + return state; + } + + return projectActiveEnvironmentState({ + activeEnvironmentId: state.activeEnvironmentId, + environmentStateById, + }); } -export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { +function syncEnvironmentReadModel( + state: EnvironmentState, + readModel: OrchestrationReadModel, + environmentId: EnvironmentId, +): EnvironmentState { const projects = readModel.projects .filter((project) => project.deletedAt === null) - .map(mapProject); - const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); + .map((project) => mapProject(project, environmentId)); + const threads = readModel.threads + .filter((thread) => thread.deletedAt === null) + .map((thread) => mapThread(thread, environmentId)); return { ...state, ...buildProjectState(projects), @@ -1010,7 +1082,23 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea }; } -export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState { +export function syncServerReadModel( + state: AppState, + readModel: OrchestrationReadModel, + environmentId: EnvironmentId, +): AppState { + return commitEnvironmentState( + state, + environmentId, + syncEnvironmentReadModel(getStoredEnvironmentState(state, environmentId), readModel, environmentId), + ); +} + +function applyEnvironmentOrchestrationEvent( + state: EnvironmentState, + event: OrchestrationEvent, + environmentId: EnvironmentId, +): EnvironmentState { switch (event.type) { case "project.created": { const nextProject = mapProject({ @@ -1022,7 +1110,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, deletedAt: null, - }); + }, environmentId); const existingProjectId = state.projectIds.find( (projectId) => @@ -1122,7 +1210,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve activities: [], checkpoints: [], session: null, - }); + }, environmentId); return writeThreadState(state, nextThread, previousThread); } @@ -1489,11 +1577,17 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve export function applyOrchestrationEvents( state: AppState, events: ReadonlyArray, + environmentId: EnvironmentId, ): AppState { if (events.length === 0) { return state; } - return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state); + const currentEnvironmentState = getStoredEnvironmentState(state, environmentId); + const nextEnvironmentState = events.reduce( + (nextState, event) => applyEnvironmentOrchestrationEvent(nextState, event, environmentId), + currentEnvironmentState, + ); + return commitEnvironmentState(state, environmentId, nextEnvironmentState); } export const selectProjects = (state: AppState): Project[] => getProjects(state); @@ -1509,65 +1603,55 @@ export const selectThreadById = export const selectSidebarThreadSummaryById = (threadId: ThreadId | null | undefined) => (state: AppState): SidebarThreadSummary | undefined => -<<<<<<< HEAD threadId ? state.sidebarThreadSummaryById[threadId] : undefined; export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => (state: AppState): ThreadId[] => projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; -======= - threadId - ? state.sidebarThreadsByScopedId[ - getThreadScopedId({ - environmentId: state.activeEnvironmentId, - id: threadId, - }) - ] - : undefined; - -export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => { - let cachedProjectScopedId: string | null = null; - let cachedScopedIds: string[] | undefined; - let cachedSidebarThreadsByScopedId: Record | undefined; - let cachedResult: ThreadId[] = EMPTY_THREAD_IDS; - - return (state: AppState): ThreadId[] => { - if (!projectId) { - return EMPTY_THREAD_IDS; - } - const projectScopedId = getProjectScopedId({ - environmentId: state.activeEnvironmentId, - id: projectId, - }); - const scopedIds = state.threadScopedIdsByProjectScopedId[projectScopedId] ?? EMPTY_SCOPED_IDS; - const sidebarThreadsByScopedId = state.sidebarThreadsByScopedId; +export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { + if (state.activeEnvironmentId === null) { + return state; + } - if ( - cachedProjectScopedId === projectScopedId && - cachedScopedIds === scopedIds && - cachedSidebarThreadsByScopedId === sidebarThreadsByScopedId - ) { - return cachedResult; - } + const nextEnvironmentState = updateThreadState( + getStoredEnvironmentState(state, state.activeEnvironmentId), + threadId, + (thread) => { + if (thread.error === error) return thread; + return { ...thread, error }; + }, + ); + return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState); +} - const result = scopedIds - .map((scopedId) => sidebarThreadsByScopedId[scopedId]?.id ?? null) - .filter((threadId): threadId is ThreadId => threadId !== null); +export function applyOrchestrationEvent( + state: AppState, + event: OrchestrationEvent, + environmentId: EnvironmentId, +): AppState { + return commitEnvironmentState( + state, + environmentId, + applyEnvironmentOrchestrationEvent( + getStoredEnvironmentState(state, environmentId), + event, + environmentId, + ), + ); +} - cachedProjectScopedId = projectScopedId; - cachedScopedIds = scopedIds; - cachedSidebarThreadsByScopedId = sidebarThreadsByScopedId; - cachedResult = result; - return result; - }; -}; ->>>>>>> 2540f09bb (Treat explicit null environments distinctly) +export function setActiveEnvironmentId( + state: AppState, + environmentId: EnvironmentId, +): AppState { + if (state.activeEnvironmentId === environmentId) { + return state; + } -export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { - return updateThreadState(state, threadId, (thread) => { - if (thread.error === error) return thread; - return { ...thread, error }; + return projectActiveEnvironmentState({ + activeEnvironmentId: environmentId, + environmentStateById: state.environmentStateById, }); } @@ -1577,31 +1661,49 @@ export function setThreadBranch( branch: string | null, worktreePath: string | null, ): AppState { - return updateThreadState(state, threadId, (thread) => { - if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; - const cwdChanged = thread.worktreePath !== worktreePath; - return { - ...thread, - branch, - worktreePath, - ...(cwdChanged ? { session: null } : {}), - }; - }); + if (state.activeEnvironmentId === null) { + return state; + } + + const nextEnvironmentState = updateThreadState( + getStoredEnvironmentState(state, state.activeEnvironmentId), + threadId, + (thread) => { + if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; + const cwdChanged = thread.worktreePath !== worktreePath; + return { + ...thread, + branch, + worktreePath, + ...(cwdChanged ? { session: null } : {}), + }; + }, + ); + return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState); } interface AppStore extends AppState { - syncServerReadModel: (readModel: OrchestrationReadModel) => void; - applyOrchestrationEvent: (event: OrchestrationEvent) => void; - applyOrchestrationEvents: (events: ReadonlyArray) => void; + setActiveEnvironmentId: (environmentId: EnvironmentId) => void; + syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void; + applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void; + applyOrchestrationEvents: ( + events: ReadonlyArray, + environmentId: EnvironmentId, + ) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void; } export const useStore = create((set) => ({ ...initialState, - syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), - applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)), - applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)), + setActiveEnvironmentId: (environmentId) => + set((state) => setActiveEnvironmentId(state, environmentId)), + syncServerReadModel: (readModel, environmentId) => + set((state) => syncServerReadModel(state, readModel, environmentId)), + applyOrchestrationEvent: (event, environmentId) => + set((state) => applyOrchestrationEvent(state, event, environmentId)), + applyOrchestrationEvents: (events, environmentId) => + set((state) => applyOrchestrationEvents(state, events, environmentId)), setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadId, branch, worktreePath) => set((state) => setThreadBranch(state, threadId, branch, worktreePath)), diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index a54b195428..a544975731 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -82,7 +82,7 @@ export interface TurnDiffSummary { export interface Project { id: ProjectId; - environmentId?: EnvironmentId | null; + environmentId: EnvironmentId; name: string; cwd: string; repositoryIdentity?: RepositoryIdentity | null; @@ -94,7 +94,7 @@ export interface Project { export interface Thread { id: ThreadId; - environmentId?: EnvironmentId | null; + environmentId: EnvironmentId; codexThreadId: string | null; projectId: ProjectId; title: string; @@ -118,6 +118,7 @@ export interface Thread { export interface ThreadShell { id: ThreadId; + environmentId: EnvironmentId; codexThreadId: string | null; projectId: ProjectId; title: string; @@ -139,7 +140,7 @@ export interface ThreadTurnState { export interface SidebarThreadSummary { id: ThreadId; - environmentId?: EnvironmentId | null; + environmentId: EnvironmentId; projectId: ProjectId; title: string; interactionMode: ProviderInteractionMode; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 723661ccbb..1af904495c 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -1,12 +1,15 @@ -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", From 97ba9f98ceae01d0ee53faeef0d0309a8917db7e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 11:46:58 -0700 Subject: [PATCH 09/48] Preserve remote scoped state across snapshot syncs - keep untouched environment sidebar entries and project thread indexes stable during local snapshot sync - avoid eagerly resolving bootstrap URLs when an explicit server URL is provided - tighten scoped helpers by removing unused environment/session utilities --- apps/web/src/lib/utils.test.ts | 16 +- apps/web/src/lib/utils.ts | 2 +- apps/web/src/store.test.ts | 178 ++++++++++++++++++ apps/web/src/store.ts | 138 ++++++++++++++ apps/web/src/storeSelectors.ts | 10 + .../client-runtime/src/knownEnvironment.ts | 13 +- packages/client-runtime/src/scoped.ts | 41 +--- 7 files changed, 345 insertions(+), 53 deletions(-) diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts index 92cb092392..3f4b0ab0d2 100644 --- a/apps/web/src/lib/utils.test.ts +++ b/apps/web/src/lib/utils.test.ts @@ -1,7 +1,11 @@ import { assert, describe, expect, it, vi } from "vitest"; +const { resolvePrimaryEnvironmentBootstrapUrlMock } = vi.hoisted(() => ({ + resolvePrimaryEnvironmentBootstrapUrlMock: vi.fn(() => "http://bootstrap.test:4321"), +})); + vi.mock("../environmentBootstrap", () => ({ - resolvePrimaryEnvironmentBootstrapUrl: vi.fn(() => "http://bootstrap.test:4321"), + resolvePrimaryEnvironmentBootstrapUrl: resolvePrimaryEnvironmentBootstrapUrlMock, })); import { isWindowsPlatform } from "./utils"; @@ -34,4 +38,14 @@ describe("resolveServerUrl", () => { }), ).toBe("wss://override.test:9999/rpc?hello=world"); }); + + it("does not evaluate the bootstrap resolver when an explicit URL is provided", () => { + resolvePrimaryEnvironmentBootstrapUrlMock.mockImplementationOnce(() => { + throw new Error("bootstrap unavailable"); + }); + + expect(resolveServerUrl({ url: "https://override.test:9999" })).toBe( + "https://override.test:9999/", + ); + }); }); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 7c3b5e4590..a74be99d78 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -53,7 +53,7 @@ export const resolveServerUrl = (options?: { pathname?: string | undefined; searchParams?: Record | undefined; }): string => { - const rawUrl = firstNonEmptyString(options?.url, resolvePrimaryEnvironmentBootstrapUrl()); + const rawUrl = firstNonEmptyString(options?.url ?? resolvePrimaryEnvironmentBootstrapUrl()); const parsedUrl = new URL(rawUrl); if (options?.protocol) { diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 2fef45fed6..943d3f17b3 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -440,7 +440,185 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); +<<<<<<< HEAD expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); +======= + expect(next.projects.map((project) => project.id)).toEqual([project1, project2, project3]); + }); + + it("replaces only the targeted environment during snapshot sync", () => { + const localThread = makeThread(); + const remoteThread = makeThread({ + id: ThreadId.makeUnsafe("thread-remote"), + projectId: ProjectId.makeUnsafe("project-remote"), + environmentId: remoteEnvironmentId, + title: "Remote thread", + }); + const initialState: AppState = { + ...makeState(localThread), + projects: [ + ...makeState(localThread).projects, + { + id: ProjectId.makeUnsafe("project-remote"), + environmentId: remoteEnvironmentId, + name: "Remote project", + cwd: "/tmp/remote-project", + repositoryIdentity: null, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + scripts: [], + }, + ], + threads: [localThread, remoteThread], + }; + + const next = syncServerReadModel( + initialState, + makeReadModel( + makeReadModelThread({ + title: "Updated local thread", + }), + ), + localEnvironmentId, + ); + + expect(next.projects).toHaveLength(2); + expect(next.threads).toHaveLength(2); + expect(next.threads.find((thread) => thread.environmentId === remoteEnvironmentId)?.title).toBe( + "Remote thread", + ); + expect(next.threads.find((thread) => thread.environmentId === localEnvironmentId)?.title).toBe( + "Updated local thread", + ); + }); + + it("preserves sidebar index references for untouched environments during snapshot sync", () => { + const localThread = makeThread(); + const remoteProjectId = ProjectId.makeUnsafe("project-remote"); + const remoteThread = makeThread({ + id: ThreadId.makeUnsafe("thread-remote"), + projectId: remoteProjectId, + environmentId: remoteEnvironmentId, + title: "Remote thread", + }); + const remoteThreadScopedId = getThreadScopedId({ + environmentId: remoteEnvironmentId, + id: remoteThread.id, + }); + const remoteProjectScopedId = getProjectScopedId({ + environmentId: remoteEnvironmentId, + id: remoteProjectId, + }); + const remoteSidebarSummary = { + id: remoteThread.id, + environmentId: remoteEnvironmentId, + projectId: remoteProjectId, + title: remoteThread.title, + interactionMode: remoteThread.interactionMode, + session: remoteThread.session, + createdAt: remoteThread.createdAt, + archivedAt: remoteThread.archivedAt, + updatedAt: remoteThread.updatedAt, + latestTurn: remoteThread.latestTurn, + branch: remoteThread.branch, + worktreePath: remoteThread.worktreePath, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } as const; + const remoteProjectThreadIds = [remoteThreadScopedId]; + const initialState: AppState = { + ...makeState(localThread), + projects: [ + ...makeState(localThread).projects, + { + id: remoteProjectId, + environmentId: remoteEnvironmentId, + name: "Remote project", + cwd: "/tmp/remote-project", + repositoryIdentity: null, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + scripts: [], + }, + ], + threads: [localThread, remoteThread], + sidebarThreadsByScopedId: { + [getThreadScopedId({ + environmentId: localEnvironmentId, + id: localThread.id, + })]: { + id: localThread.id, + environmentId: localEnvironmentId, + projectId: localThread.projectId, + title: localThread.title, + interactionMode: localThread.interactionMode, + session: localThread.session, + createdAt: localThread.createdAt, + archivedAt: localThread.archivedAt, + updatedAt: localThread.updatedAt, + latestTurn: localThread.latestTurn, + branch: localThread.branch, + worktreePath: localThread.worktreePath, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }, + [remoteThreadScopedId]: remoteSidebarSummary, + }, + threadScopedIdsByProjectScopedId: { + [getProjectScopedId({ + environmentId: localEnvironmentId, + id: localThread.projectId, + })]: [ + getThreadScopedId({ + environmentId: localEnvironmentId, + id: localThread.id, + }), + ], + [remoteProjectScopedId]: remoteProjectThreadIds, + }, + }; + + const next = syncServerReadModel( + initialState, + makeReadModel( + makeReadModelThread({ + title: "Updated local thread", + }), + ), + localEnvironmentId, + ); + + expect(next.sidebarThreadsByScopedId[remoteThreadScopedId]).toBe(remoteSidebarSummary); + expect(next.threadScopedIdsByProjectScopedId[remoteProjectScopedId]).toBe( + remoteProjectThreadIds, + ); + }); + + it("returns a stable thread id array for unchanged project thread inputs", () => { + const projectId = ProjectId.makeUnsafe("project-1"); + const syncedState = syncServerReadModel( + makeState(makeThread()), + makeReadModel(makeReadModelThread({ projectId })), + localEnvironmentId, + ); + const selectThreadIds = selectThreadIdsByProjectId(projectId); + + const first = selectThreadIds(syncedState); + const second = selectThreadIds({ + ...syncedState, + bootstrapComplete: false, + }); + + expect(first).toBe(second); +>>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) }); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 54429f9c15..c7d1e72a8e 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -385,6 +385,7 @@ function buildProposedPlanSlice(thread: Thread): { }; } +<<<<<<< HEAD function buildTurnDiffSlice(thread: Thread): { ids: TurnId[]; byId: Record; @@ -693,6 +694,60 @@ function removeThreadState(state: EnvironmentState, threadId: ThreadId): Environ turnDiffSummaryByThreadId, sidebarThreadSummaryById, }; +======= +function isScopedRecordKeyForEnvironment( + scopedRecordKey: string, + environmentId: EnvironmentId, + kind: "project" | "thread", +): boolean { + return scopedRecordKey.startsWith(`${environmentId}:${kind}:`); +} + +function mergeSidebarThreadsByScopedId( + current: AppState["sidebarThreadsByScopedId"], + environmentId: EnvironmentId, + threadsForEnvironment: ReadonlyArray, +): AppState["sidebarThreadsByScopedId"] { + const next = Object.fromEntries( + Object.entries(current).filter( + ([scopedId]) => !isScopedRecordKeyForEnvironment(scopedId, environmentId, "thread"), + ), + ); + + for (const thread of threadsForEnvironment) { + const scopedId = getThreadScopedId({ + environmentId: thread.environmentId, + id: thread.id, + }); + const nextSummary = buildSidebarThreadSummary(thread); + const previousSummary = current[scopedId]; + next[scopedId] = + sidebarThreadSummariesEqual(previousSummary, nextSummary) && previousSummary !== undefined + ? previousSummary + : nextSummary; + } + + return next; +} + +function mergeThreadScopedIdsByProjectScopedId( + current: AppState["threadScopedIdsByProjectScopedId"], + environmentId: EnvironmentId, + threadsForEnvironment: ReadonlyArray, +): AppState["threadScopedIdsByProjectScopedId"] { + const next = Object.fromEntries( + Object.entries(current).filter( + ([scopedId]) => !isScopedRecordKeyForEnvironment(scopedId, environmentId, "project"), + ), + ); + const nextEntries = buildThreadScopedIdsByProjectScopedId(threadsForEnvironment); + + for (const [scopedId, threadScopedIds] of Object.entries(nextEntries)) { + next[scopedId] = threadScopedIds; + } + + return next; +>>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) } function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { @@ -1087,11 +1142,46 @@ export function syncServerReadModel( readModel: OrchestrationReadModel, environmentId: EnvironmentId, ): AppState { +<<<<<<< HEAD return commitEnvironmentState( state, environmentId, syncEnvironmentReadModel(getStoredEnvironmentState(state, environmentId), readModel, environmentId), ); +======= + const projectsForEnvironment = readModel.projects + .filter((project) => project.deletedAt === null) + .map((project) => mapProject(project, environmentId)); + const threadsForEnvironment = readModel.threads + .filter((thread) => thread.deletedAt === null) + .map((thread) => mapThread(thread, environmentId)); + const projects = [ + ...state.projects.filter((project) => !sameEnvironmentId(project.environmentId, environmentId)), + ...projectsForEnvironment, + ]; + const threads = [ + ...state.threads.filter((thread) => !sameEnvironmentId(thread.environmentId, environmentId)), + ...threadsForEnvironment, + ]; + const sidebarThreadsByScopedId = mergeSidebarThreadsByScopedId( + state.sidebarThreadsByScopedId, + environmentId, + threadsForEnvironment, + ); + const threadScopedIdsByProjectScopedId = mergeThreadScopedIdsByProjectScopedId( + state.threadScopedIdsByProjectScopedId, + environmentId, + threadsForEnvironment, + ); + return { + ...state, + projects, + threads, + sidebarThreadsByScopedId, + threadScopedIdsByProjectScopedId, + bootstrapComplete: true, + }; +>>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) } function applyEnvironmentOrchestrationEvent( @@ -1599,6 +1689,7 @@ export const selectProjectById = export const selectThreadById = (threadId: ThreadId | null | undefined) => (state: AppState): Thread | undefined => +<<<<<<< HEAD threadId ? getThread(state, threadId) : undefined; export const selectSidebarThreadSummaryById = (threadId: ThreadId | null | undefined) => @@ -1608,6 +1699,53 @@ export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => (state: AppState): ThreadId[] => projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; +======= + threadId + ? state.threads.find( + (thread) => + thread.id === threadId && + sameEnvironmentId(thread.environmentId, state.activeEnvironmentId), + ) + : undefined; + +export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => { + let cachedProjectScopedId: string | null = null; + let cachedScopedIds: string[] | undefined; + let cachedSidebarThreadsByScopedId: Record | undefined; + let cachedResult: ThreadId[] = EMPTY_THREAD_IDS; + + return (state: AppState): ThreadId[] => { + if (!projectId) { + return EMPTY_THREAD_IDS; + } + + const projectScopedId = getProjectScopedId({ + environmentId: state.activeEnvironmentId, + id: projectId, + }); + const scopedIds = state.threadScopedIdsByProjectScopedId[projectScopedId] ?? EMPTY_SCOPED_IDS; + const sidebarThreadsByScopedId = state.sidebarThreadsByScopedId; + + if ( + cachedProjectScopedId === projectScopedId && + cachedScopedIds === scopedIds && + cachedSidebarThreadsByScopedId === sidebarThreadsByScopedId + ) { + return cachedResult; + } + + const result = scopedIds + .map((scopedId) => sidebarThreadsByScopedId[scopedId]?.id ?? null) + .filter((threadId): threadId is ThreadId => threadId !== null); + + cachedProjectScopedId = projectScopedId; + cachedScopedIds = scopedIds; + cachedSidebarThreadsByScopedId = sidebarThreadsByScopedId; + cachedResult = result; + return result; + }; +}; +>>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { if (state.activeEnvironmentId === null) { diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index a7a7440eb2..be70a86b13 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,3 +1,4 @@ +<<<<<<< HEAD import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts"; import { type AppState } from "./store"; import { @@ -10,6 +11,12 @@ import { type ThreadTurnState, type TurnDiffSummary, } from "./types"; +======= +import { type ThreadId } from "@t3tools/contracts"; +import { useMemo } from "react"; +import { selectProjectById, selectThreadById, useStore } from "./store"; +import { type Project, type Thread } from "./types"; +>>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) const EMPTY_MESSAGES: ChatMessage[] = []; const EMPTY_ACTIVITIES: Thread["activities"] = []; @@ -35,6 +42,7 @@ export function createProjectSelector( ): (state: AppState) => Project | undefined { return (state) => (projectId ? state.projectById[projectId] : undefined); } +<<<<<<< HEAD export function createSidebarThreadSummarySelector( threadId: ThreadId | null | undefined, @@ -144,3 +152,5 @@ export function createThreadSelector( return previousThread; }; } +======= +>>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) diff --git a/packages/client-runtime/src/knownEnvironment.ts b/packages/client-runtime/src/knownEnvironment.ts index 40c9054f17..3a5e0d0e7d 100644 --- a/packages/client-runtime/src/knownEnvironment.ts +++ b/packages/client-runtime/src/knownEnvironment.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import type { EnvironmentId } from "@t3tools/contracts"; export interface KnownEnvironmentConnectionTarget { readonly type: "ws"; @@ -37,14 +37,3 @@ export function getKnownEnvironmentBaseUrl( ): string | null { return environment?.target.wsUrl ?? null; } - -export function attachEnvironmentDescriptor( - environment: KnownEnvironment, - descriptor: ExecutionEnvironmentDescriptor, -): KnownEnvironment { - return { - ...environment, - environmentId: descriptor.environmentId, - label: descriptor.label, - }; -} diff --git a/packages/client-runtime/src/scoped.ts b/packages/client-runtime/src/scoped.ts index 54ffd6dcbb..7fc721cb3a 100644 --- a/packages/client-runtime/src/scoped.ts +++ b/packages/client-runtime/src/scoped.ts @@ -2,20 +2,10 @@ import type { EnvironmentId, ProjectId, ScopedProjectRef, - ScopedThreadSessionRef, ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; -interface EnvironmentScopedRef { - readonly environmentId: EnvironmentId; - readonly id: TId; -} - -export interface EnvironmentClientRegistry { - readonly getClient: (environmentId: EnvironmentId) => TClient | null | undefined; -} - export function scopeProjectRef( environmentId: EnvironmentId, projectId: ProjectId, @@ -27,34 +17,7 @@ export function scopeThreadRef(environmentId: EnvironmentId, threadId: ThreadId) return { environmentId, threadId }; } -export function scopeThreadSessionRef( - environmentId: EnvironmentId, - threadId: ThreadId, -): ScopedThreadSessionRef { - return { environmentId, threadId }; -} - -export function scopedRefKey( - ref: EnvironmentScopedRef | ScopedProjectRef | ScopedThreadRef | ScopedThreadSessionRef, -): string { - const localId = "id" in ref ? ref.id : "projectId" in ref ? ref.projectId : ref.threadId; +export function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string { + const localId = "projectId" in ref ? ref.projectId : ref.threadId; return `${ref.environmentId}:${localId}`; } - -export function resolveEnvironmentClient( - registry: EnvironmentClientRegistry, - ref: EnvironmentScopedRef, -): TClient { - const client = registry.getClient(ref.environmentId); - if (!client) { - throw new Error(`No client registered for environment ${ref.environmentId}.`); - } - return client; -} - -export function tagEnvironmentValue( - environmentId: EnvironmentId, - value: T, -): { readonly environmentId: EnvironmentId; readonly value: T } { - return { environmentId, value }; -} From 74250e92684c1952135667524afd69078f55be75 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 11:55:07 -0700 Subject: [PATCH 10/48] non empty --- apps/web/src/lib/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index a74be99d78..7c3b5e4590 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -53,7 +53,7 @@ export const resolveServerUrl = (options?: { pathname?: string | undefined; searchParams?: Record | undefined; }): string => { - const rawUrl = firstNonEmptyString(options?.url ?? resolvePrimaryEnvironmentBootstrapUrl()); + const rawUrl = firstNonEmptyString(options?.url, resolvePrimaryEnvironmentBootstrapUrl()); const parsedUrl = new URL(rawUrl); if (options?.protocol) { From 240438d23877447f1e8943c7912e12b7ee6e113b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 11:58:27 -0700 Subject: [PATCH 11/48] fine --- apps/web/src/lib/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 7c3b5e4590..b94963b9c4 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -53,7 +53,7 @@ export const resolveServerUrl = (options?: { pathname?: string | undefined; searchParams?: Record | undefined; }): string => { - const rawUrl = firstNonEmptyString(options?.url, resolvePrimaryEnvironmentBootstrapUrl()); + const rawUrl = firstNonEmptyString(options?.url || resolvePrimaryEnvironmentBootstrapUrl()); const parsedUrl = new URL(rawUrl); if (options?.protocol) { From 938f3d68f3f2b7963d849344a5801fc2a52f2d5a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 12:09:03 -0700 Subject: [PATCH 12/48] Cache repository identity lookups by TTL - add TTL-backed positive and negative caching for repository identity resolution - refresh identities when remotes appear or change after cache expiry - cover late-remote and remote-change cases in tests --- .../Layers/RepositoryIdentityResolver.test.ts | 100 +++++++++++++++--- 1 file changed, 87 insertions(+), 13 deletions(-) diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 2992f17178..9f247b2a07 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -1,14 +1,30 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { Effect, FileSystem } from "effect"; +import { Duration, Effect, FileSystem, Layer } from "effect"; +import { TestClock } from "effect/testing"; import { runProcess } from "../../processRunner.ts"; import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; -import { RepositoryIdentityResolverLive } from "./RepositoryIdentityResolver.ts"; +import { + makeRepositoryIdentityResolver, + RepositoryIdentityResolverLive, +} from "./RepositoryIdentityResolver.ts"; const git = (cwd: string, args: ReadonlyArray) => Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); +const makeRepositoryIdentityResolverTestLayer = (options: { + readonly positiveCacheTtl?: Duration.Input; + readonly negativeCacheTtl?: Duration.Input; +}) => + Layer.effect( + RepositoryIdentityResolver, + makeRepositoryIdentityResolver({ + cacheCapacity: 16, + ...options, + }), + ); + it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => Effect.gen(function* () { @@ -95,25 +111,83 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); - it.effect("re-resolves after a remote is configured later in the same process", () => + it.effect( + "refreshes cached null identities after the negative TTL when a remote is configured later", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-late-remote-test-", + }); + + yield* git(cwd, ["init"]); + + const resolver = yield* RepositoryIdentityResolver; + const initialIdentity = yield* resolver.resolve(cwd); + expect(initialIdentity).toBeNull(); + + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const cachedIdentity = yield* resolver.resolve(cwd); + expect(cachedIdentity).toBeNull(); + + yield* TestClock.adjust(Duration.millis(120)); + + const refreshedIdentity = yield* resolver.resolve(cwd); + expect(refreshedIdentity).not.toBeNull(); + expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(refreshedIdentity?.name).toBe("t3code"); + }).pipe( + Effect.provide( + Layer.merge( + TestClock.layer(), + makeRepositoryIdentityResolverTestLayer({ + negativeCacheTtl: Duration.millis(50), + positiveCacheTtl: Duration.seconds(1), + }), + ), + ), + ), + ); + + it.effect("refreshes cached identities after the positive TTL when a remote changes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-late-remote-test-", + prefix: "t3-repository-identity-remote-change-test-", }); yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); const resolver = yield* RepositoryIdentityResolver; const initialIdentity = yield* resolver.resolve(cwd); - expect(initialIdentity).toBeNull(); - - yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - - const resolvedIdentity = yield* resolver.resolve(cwd); - expect(resolvedIdentity).not.toBeNull(); - expect(resolvedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(resolvedIdentity?.name).toBe("t3code"); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + expect(initialIdentity).not.toBeNull(); + expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + + yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]); + + const cachedIdentity = yield* resolver.resolve(cwd); + expect(cachedIdentity).not.toBeNull(); + expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + + yield* TestClock.adjust(Duration.millis(180)); + + const refreshedIdentity = yield* resolver.resolve(cwd); + expect(refreshedIdentity).not.toBeNull(); + expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next"); + expect(refreshedIdentity?.displayName).toBe("t3tools/t3code-next"); + expect(refreshedIdentity?.name).toBe("t3code-next"); + }).pipe( + Effect.provide( + Layer.merge( + TestClock.layer(), + makeRepositoryIdentityResolverTestLayer({ + negativeCacheTtl: Duration.millis(50), + positiveCacheTtl: Duration.millis(100), + }), + ), + ), + ), ); }); From fc2de818221d047e4b5e07931d8f9b19b273f4b6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 12:36:28 -0700 Subject: [PATCH 13/48] move --- apps/server/src/config.ts | 2 ++ apps/server/src/environment/Layers/ServerEnvironment.ts | 9 +++------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 9ceea4c13c..887eb11c4f 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -30,6 +30,7 @@ export interface ServerDerivedPaths { readonly providerEventLogPath: string; readonly terminalLogsDir: string; readonly anonymousIdPath: string; + readonly environmentIdPath: string; } /** @@ -83,6 +84,7 @@ export const deriveServerPaths = Effect.fn(function* ( providerEventLogPath: join(providerLogsDir, "events.log"), terminalLogsDir: join(logsDir, "terminals"), anonymousIdPath: join(stateDir, "anonymous-id"), + environmentIdPath: join(stateDir, "environment-id"), }; }); diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts index 2978af7dcb..1e088724c7 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -6,8 +6,6 @@ import { ServerConfig } from "../../config.ts"; import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; import { version } from "../../../package.json" with { type: "json" }; -const ENVIRONMENT_ID_FILENAME = "environment-id"; - function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { switch (process.platform) { case "darwin": @@ -36,17 +34,16 @@ export const makeServerEnvironment = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; - const environmentIdPath = path.join(serverConfig.stateDir, ENVIRONMENT_ID_FILENAME); const readPersistedEnvironmentId = Effect.gen(function* () { const exists = yield* fileSystem - .exists(environmentIdPath) + .exists(serverConfig.environmentIdPath) .pipe(Effect.orElseSucceed(() => false)); if (!exists) { return null; } - const raw = yield* fileSystem.readFileString(environmentIdPath).pipe( + const raw = yield* fileSystem.readFileString(serverConfig.environmentIdPath).pipe( Effect.orElseSucceed(() => ""), Effect.map((value) => value.trim()), ); @@ -55,7 +52,7 @@ export const makeServerEnvironment = Effect.gen(function* () { }); const persistEnvironmentId = (value: string) => - fileSystem.writeFileString(environmentIdPath, `${value}\n`); + fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); const environmentIdRaw = yield* readPersistedEnvironmentId.pipe( Effect.flatMap((persisted) => { From 403ac968e964ea1cfb90a97fe98d3d29f7a53652 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 12:47:14 -0700 Subject: [PATCH 14/48] kewl --- .../environment/Layers/ServerEnvironment.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts index 1e088724c7..75812239d2 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -1,6 +1,5 @@ -import { randomUUID } from "node:crypto"; import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Effect, FileSystem, Layer, Path, Random } from "effect"; import { ServerConfig } from "../../config.ts"; import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; @@ -30,7 +29,7 @@ function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] { } } -export const makeServerEnvironment = Effect.gen(function* () { +export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; @@ -54,16 +53,16 @@ export const makeServerEnvironment = Effect.gen(function* () { const persistEnvironmentId = (value: string) => fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); - const environmentIdRaw = yield* readPersistedEnvironmentId.pipe( - Effect.flatMap((persisted) => { - if (persisted) { - return Effect.succeed(persisted); - } + const environmentIdRaw = yield* Effect.gen(function* () { + const persisted = yield* readPersistedEnvironmentId; + if (persisted) { + return persisted; + } - const generated = randomUUID(); - return persistEnvironmentId(generated).pipe(Effect.as(generated)); - }), - ); + const generated = yield* Random.nextUUIDv4; + yield* persistEnvironmentId(generated); + return generated; + }); const environmentId = EnvironmentId.makeUnsafe(environmentIdRaw); const cwdBaseName = path.basename(serverConfig.cwd).trim(); @@ -93,4 +92,4 @@ export const makeServerEnvironment = Effect.gen(function* () { } satisfies ServerEnvironmentShape; }); -export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment); +export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()); From 70d373163b491ae9ddfa01d3de22a685911aeede Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 12:51:11 -0700 Subject: [PATCH 15/48] Handle ports in remote URL normalization - Strip explicit ports from URL-style git remotes - Add regression coverage for HTTPS and SSH remotes --- packages/shared/src/git.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 55871f8ed5..a39c924447 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -89,13 +89,25 @@ export function normalizeGitRemoteUrl(value: string): string { .replace(/\/+$/g, "") .replace(/\.git$/i, "") .toLowerCase(); - const hostAndPath = - /^(?:git@|ssh:\/\/git@|https:\/\/|http:\/\/|git:\/\/)([^/:]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec( - normalized, - ); - if (hostAndPath?.[1] && hostAndPath[2]) { - return `${hostAndPath[1]}/${hostAndPath[2]}`; + if (/^(?:ssh|https?|git):\/\//i.test(normalized)) { + try { + const url = new URL(normalized); + const repositoryPath = url.pathname + .split("/") + .filter((segment) => segment.length > 0) + .join("/"); + if (url.hostname && repositoryPath.includes("/")) { + return `${url.hostname}/${repositoryPath}`; + } + } catch { + return normalized; + } + } + + const scpStyleHostAndPath = /^git@([^:/\s]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized); + if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) { + return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`; } return normalized; From babe73c90c25ae2382585b86c633873b8fcee0c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 12:58:07 -0700 Subject: [PATCH 16/48] Handle empty server URLs and stale sidebar threads - Preserve persisted environment IDs on read failures - Scope sidebar thread lookups to the active environment - Treat empty server URLs as unset --- .../Layers/ServerEnvironment.test.ts | 109 +++++++++++++++++- .../environment/Layers/ServerEnvironment.ts | 7 +- apps/web/src/lib/utils.test.ts | 4 + apps/web/src/lib/utils.ts | 12 +- 4 files changed, 117 insertions(+), 15 deletions(-) diff --git a/apps/server/src/environment/Layers/ServerEnvironment.test.ts b/apps/server/src/environment/Layers/ServerEnvironment.test.ts index 35b6803fc9..4042eaac71 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.test.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.test.ts @@ -1,14 +1,58 @@ +import * as nodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer } from "effect"; +import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect"; -import { ServerConfig } from "../../config.ts"; +import { ServerConfig, type ServerConfigShape } from "../../config.ts"; import { ServerEnvironment } from "../Services/ServerEnvironment.ts"; import { ServerEnvironmentLive } from "./ServerEnvironment.ts"; const makeServerEnvironmentLayer = (baseDir: string) => ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); +const makeServerConfig = (baseDir: string): ServerConfigShape => { + const stateDir = nodePath.join(baseDir, "userdata"); + const logsDir = nodePath.join(stateDir, "logs"); + const providerLogsDir = nodePath.join(logsDir, "provider"); + return { + logLevel: "Error", + traceMinLevel: "Info", + traceTimingEnabled: true, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + cwd: process.cwd(), + baseDir, + stateDir, + dbPath: nodePath.join(stateDir, "state.sqlite"), + keybindingsConfigPath: nodePath.join(stateDir, "keybindings.json"), + settingsPath: nodePath.join(stateDir, "settings.json"), + worktreesDir: nodePath.join(baseDir, "worktrees"), + attachmentsDir: nodePath.join(stateDir, "attachments"), + logsDir, + serverLogPath: nodePath.join(logsDir, "server.log"), + serverTracePath: nodePath.join(logsDir, "server.trace.ndjson"), + providerLogsDir, + providerEventLogPath: nodePath.join(providerLogsDir, "events.log"), + terminalLogsDir: nodePath.join(logsDir, "terminals"), + anonymousIdPath: nodePath.join(stateDir, "anonymous-id"), + environmentIdPath: nodePath.join(stateDir, "environment-id"), + mode: "web", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + port: 0, + host: undefined, + authToken: undefined, + staticDir: undefined, + devUrl: undefined, + noBrowser: false, + }; +}; + it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { it.effect("persists the environment id across service restarts", () => Effect.gen(function* () { @@ -30,4 +74,65 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.repositoryIdentity).toBe(true); }), ); + + it.effect("fails instead of overwriting a persisted id when reading the file errors", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-read-error-test-", + }); + const serverConfig = makeServerConfig(baseDir); + const environmentIdPath = serverConfig.environmentIdPath; + yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true }); + yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n"); + const writeAttempts: string[] = []; + const failingFileSystemLayer = FileSystem.layerNoop({ + exists: (path) => Effect.succeed(path === environmentIdPath), + readFileString: (path) => + path === environmentIdPath + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + description: "permission denied", + pathOrDescriptor: path, + }), + ) + : Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFileString", + description: "not found", + pathOrDescriptor: path, + }), + ), + writeFileString: (path) => { + writeAttempts.push(path); + return Effect.void; + }, + }); + + const exit = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironmentLive.pipe( + Layer.provide( + Layer.merge(Layer.succeed(ServerConfig, serverConfig), failingFileSystemLayer), + ), + ), + ), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(writeAttempts).toEqual([]); + expect(yield* fileSystem.readFileString(environmentIdPath)).toBe( + "persisted-environment-id\n", + ); + }), + ); }); diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts index 75812239d2..fd58425dee 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -42,10 +42,9 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function return null; } - const raw = yield* fileSystem.readFileString(serverConfig.environmentIdPath).pipe( - Effect.orElseSucceed(() => ""), - Effect.map((value) => value.trim()), - ); + const raw = yield* fileSystem + .readFileString(serverConfig.environmentIdPath) + .pipe(Effect.map((value) => value.trim())); return raw.length > 0 ? raw : null; }); diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts index 3f4b0ab0d2..317933d677 100644 --- a/apps/web/src/lib/utils.test.ts +++ b/apps/web/src/lib/utils.test.ts @@ -24,6 +24,10 @@ describe("isWindowsPlatform", () => { }); describe("resolveServerUrl", () => { + it("falls back to the bootstrap environment URL when the explicit URL is empty", () => { + expect(resolveServerUrl({ url: "" })).toBe("http://bootstrap.test:4321/"); + }); + it("uses the bootstrap environment URL when no explicit URL is provided", () => { expect(resolveServerUrl()).toBe("http://bootstrap.test:4321/"); }); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index b94963b9c4..2f826dda02 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -38,14 +38,6 @@ export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID()); export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID()); const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty); -const firstNonEmptyString = (...values: unknown[]): string => { - for (const value of values) { - if (isNonEmptyString(value)) { - return value; - } - } - throw new Error("No non-empty string provided"); -}; export const resolveServerUrl = (options?: { url?: string | undefined; @@ -53,7 +45,9 @@ export const resolveServerUrl = (options?: { pathname?: string | undefined; searchParams?: Record | undefined; }): string => { - const rawUrl = firstNonEmptyString(options?.url || resolvePrimaryEnvironmentBootstrapUrl()); + const rawUrl = isNonEmptyString(options?.url) + ? options.url + : resolvePrimaryEnvironmentBootstrapUrl(); const parsedUrl = new URL(rawUrl); if (options?.protocol) { From 54f905c86597a8cf74ae70cb1559ed83de393678 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 13:51:37 -0700 Subject: [PATCH 17/48] Adapt multi-environment store to atomic refactor base Co-authored-by: codex --- .../Layers/RepositoryIdentityResolver.test.ts | 6 +- .../web/src/components/ChatView.logic.test.ts | 4 + apps/web/src/routes/__root.tsx | 6 +- apps/web/src/store.test.ts | 274 ++++-------------- apps/web/src/store.ts | 263 ++++++----------- apps/web/src/storeSelectors.ts | 10 - 6 files changed, 158 insertions(+), 405 deletions(-) diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 9f247b2a07..57f4464804 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -41,9 +41,9 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(identity?.displayName).toBe("T3Tools/t3code"); + expect(identity?.displayName).toBe("t3tools/t3code"); expect(identity?.provider).toBe("github"); - expect(identity?.owner).toBe("T3Tools"); + expect(identity?.owner).toBe("t3tools"); expect(identity?.name).toBe("t3code"); }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); @@ -86,7 +86,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity).not.toBeNull(); expect(identity?.locator.remoteName).toBe("upstream"); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(identity?.displayName).toBe("T3Tools/t3code"); + expect(identity?.displayName).toBe("t3tools/t3code"); }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 03bc9ed9f5..184a1fb11c 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -212,10 +212,12 @@ const makeThread = (input?: { function setStoreThreads(threads: ReadonlyArray>) { const projectId = ProjectId.makeUnsafe("project-1"); useStore.setState({ + activeEnvironmentId: localEnvironmentId, projectIds: [projectId], projectById: { [projectId]: { id: projectId, + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -236,6 +238,7 @@ function setStoreThreads(threads: ReadonlyArray>) thread.id, { id: thread.id, + environmentId: thread.environmentId, codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, @@ -304,6 +307,7 @@ function setStoreThreads(threads: ReadonlyArray>) ), sidebarThreadSummaryById: {}, bootstrapComplete: true, + environmentStateById: {}, }); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 4cee742092..ebafc8f029 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -611,11 +611,7 @@ function EventRouter() { return; } const thread = selectThreadById(ThreadId.makeUnsafe(event.threadId))(useStore.getState()); - if ( - thread && - thread.environmentId === currentEnvironmentId && - thread.archivedAt !== null - ) { + if (thread && thread.environmentId === currentEnvironmentId && thread.archivedAt !== null) { return; } applyTerminalEvent(event); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 943d3f17b3..4ba0aa9614 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -307,7 +307,11 @@ describe("store read model sync", () => { bootstrapComplete: false, }; - const next = syncServerReadModel(initialState, makeReadModel(makeReadModelThread({}))); + const next = syncServerReadModel( + initialState, + makeReadModel(makeReadModelThread({})), + localEnvironmentId, + ); expect(next.bootstrapComplete).toBe(true); }); @@ -323,7 +327,7 @@ describe("store read model sync", () => { }), ); - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6"); }); @@ -348,7 +352,7 @@ describe("store read model sync", () => { }), ); - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); }); @@ -361,7 +365,7 @@ describe("store read model sync", () => { }), ); - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); @@ -377,6 +381,7 @@ describe("store read model sync", () => { archivedAt, }), ), + localEnvironmentId, ); expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt); @@ -391,6 +396,7 @@ describe("store read model sync", () => { projectById: { [project2]: { id: project2, + environmentId: localEnvironmentId, name: "Project 2", cwd: "/tmp/project-2", defaultModelSelection: { @@ -403,6 +409,7 @@ describe("store read model sync", () => { }, [project1]: { id: project1, + environmentId: localEnvironmentId, name: "Project 1", cwd: "/tmp/project-1", defaultModelSelection: { @@ -438,187 +445,9 @@ describe("store read model sync", () => { threads: [], }; - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); -<<<<<<< HEAD expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); -======= - expect(next.projects.map((project) => project.id)).toEqual([project1, project2, project3]); - }); - - it("replaces only the targeted environment during snapshot sync", () => { - const localThread = makeThread(); - const remoteThread = makeThread({ - id: ThreadId.makeUnsafe("thread-remote"), - projectId: ProjectId.makeUnsafe("project-remote"), - environmentId: remoteEnvironmentId, - title: "Remote thread", - }); - const initialState: AppState = { - ...makeState(localThread), - projects: [ - ...makeState(localThread).projects, - { - id: ProjectId.makeUnsafe("project-remote"), - environmentId: remoteEnvironmentId, - name: "Remote project", - cwd: "/tmp/remote-project", - repositoryIdentity: null, - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, - scripts: [], - }, - ], - threads: [localThread, remoteThread], - }; - - const next = syncServerReadModel( - initialState, - makeReadModel( - makeReadModelThread({ - title: "Updated local thread", - }), - ), - localEnvironmentId, - ); - - expect(next.projects).toHaveLength(2); - expect(next.threads).toHaveLength(2); - expect(next.threads.find((thread) => thread.environmentId === remoteEnvironmentId)?.title).toBe( - "Remote thread", - ); - expect(next.threads.find((thread) => thread.environmentId === localEnvironmentId)?.title).toBe( - "Updated local thread", - ); - }); - - it("preserves sidebar index references for untouched environments during snapshot sync", () => { - const localThread = makeThread(); - const remoteProjectId = ProjectId.makeUnsafe("project-remote"); - const remoteThread = makeThread({ - id: ThreadId.makeUnsafe("thread-remote"), - projectId: remoteProjectId, - environmentId: remoteEnvironmentId, - title: "Remote thread", - }); - const remoteThreadScopedId = getThreadScopedId({ - environmentId: remoteEnvironmentId, - id: remoteThread.id, - }); - const remoteProjectScopedId = getProjectScopedId({ - environmentId: remoteEnvironmentId, - id: remoteProjectId, - }); - const remoteSidebarSummary = { - id: remoteThread.id, - environmentId: remoteEnvironmentId, - projectId: remoteProjectId, - title: remoteThread.title, - interactionMode: remoteThread.interactionMode, - session: remoteThread.session, - createdAt: remoteThread.createdAt, - archivedAt: remoteThread.archivedAt, - updatedAt: remoteThread.updatedAt, - latestTurn: remoteThread.latestTurn, - branch: remoteThread.branch, - worktreePath: remoteThread.worktreePath, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - } as const; - const remoteProjectThreadIds = [remoteThreadScopedId]; - const initialState: AppState = { - ...makeState(localThread), - projects: [ - ...makeState(localThread).projects, - { - id: remoteProjectId, - environmentId: remoteEnvironmentId, - name: "Remote project", - cwd: "/tmp/remote-project", - repositoryIdentity: null, - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, - scripts: [], - }, - ], - threads: [localThread, remoteThread], - sidebarThreadsByScopedId: { - [getThreadScopedId({ - environmentId: localEnvironmentId, - id: localThread.id, - })]: { - id: localThread.id, - environmentId: localEnvironmentId, - projectId: localThread.projectId, - title: localThread.title, - interactionMode: localThread.interactionMode, - session: localThread.session, - createdAt: localThread.createdAt, - archivedAt: localThread.archivedAt, - updatedAt: localThread.updatedAt, - latestTurn: localThread.latestTurn, - branch: localThread.branch, - worktreePath: localThread.worktreePath, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - }, - [remoteThreadScopedId]: remoteSidebarSummary, - }, - threadScopedIdsByProjectScopedId: { - [getProjectScopedId({ - environmentId: localEnvironmentId, - id: localThread.projectId, - })]: [ - getThreadScopedId({ - environmentId: localEnvironmentId, - id: localThread.id, - }), - ], - [remoteProjectScopedId]: remoteProjectThreadIds, - }, - }; - - const next = syncServerReadModel( - initialState, - makeReadModel( - makeReadModelThread({ - title: "Updated local thread", - }), - ), - localEnvironmentId, - ); - - expect(next.sidebarThreadsByScopedId[remoteThreadScopedId]).toBe(remoteSidebarSummary); - expect(next.threadScopedIdsByProjectScopedId[remoteProjectScopedId]).toBe( - remoteProjectThreadIds, - ); - }); - - it("returns a stable thread id array for unchanged project thread inputs", () => { - const projectId = ProjectId.makeUnsafe("project-1"); - const syncedState = syncServerReadModel( - makeState(makeThread()), - makeReadModel(makeReadModelThread({ projectId })), - localEnvironmentId, - ); - const selectThreadIds = selectThreadIdsByProjectId(projectId); - - const first = selectThreadIds(syncedState); - const second = selectThreadIds({ - ...syncedState, - bootstrapComplete: false, - }); - - expect(first).toBe(second); ->>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) }); }); @@ -636,6 +465,7 @@ describe("incremental orchestration updates", () => { title: "Updated title", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(next.bootstrapComplete).toBe(false); @@ -651,6 +481,7 @@ describe("incremental orchestration updates", () => { projectId: ProjectId.makeUnsafe("project-missing"), deletedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); const nextAfterThreadDelete = applyOrchestrationEvent( state, @@ -658,6 +489,7 @@ describe("incremental orchestration updates", () => { threadId: ThreadId.makeUnsafe("thread-missing"), deletedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(nextAfterProjectDelete).toBe(state); @@ -672,6 +504,7 @@ describe("incremental orchestration updates", () => { projectById: { [originalProjectId]: { id: originalProjectId, + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -699,6 +532,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(projectsOf(next)).toHaveLength(1); @@ -724,6 +558,7 @@ describe("incremental orchestration updates", () => { projectById: { [originalProjectId]: { id: originalProjectId, + environmentId: localEnvironmentId, name: "Project 1", cwd: "/tmp/project-1", defaultModelSelection: { @@ -736,6 +571,7 @@ describe("incremental orchestration updates", () => { }, [recreatedProjectId]: { id: recreatedProjectId, + environmentId: localEnvironmentId, name: "Project 2", cwd: "/tmp/project-2", defaultModelSelection: { @@ -766,6 +602,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)).toHaveLength(1); @@ -797,6 +634,7 @@ describe("incremental orchestration updates", () => { ...makeState(thread1).threadShellById, [thread2.id]: { id: thread2.id, + environmentId: thread2.environmentId, codexThreadId: thread2.codexThreadId, projectId: thread2.projectId, title: thread2.title, @@ -873,6 +711,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); @@ -896,38 +735,42 @@ describe("incremental orchestration updates", () => { }); const state = makeState(thread); - const next = applyOrchestrationEvents(state, [ - makeEvent( - "thread.session-set", - { - threadId: thread.id, - session: { + const next = applyOrchestrationEvents( + state, + [ + makeEvent( + "thread.session-set", + { threadId: thread.id, - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: TurnId.makeUnsafe("turn-1"), - lastError: null, - updatedAt: "2026-02-27T00:00:02.000Z", + session: { + threadId: thread.id, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: TurnId.makeUnsafe("turn-1"), + lastError: null, + updatedAt: "2026-02-27T00:00:02.000Z", + }, }, - }, - { sequence: 2 }, - ), - makeEvent( - "thread.message-sent", - { - threadId: thread.id, - messageId: MessageId.makeUnsafe("assistant-1"), - role: "assistant", - text: "done", - turnId: TurnId.makeUnsafe("turn-1"), - streaming: false, - createdAt: "2026-02-27T00:00:03.000Z", - updatedAt: "2026-02-27T00:00:03.000Z", - }, - { sequence: 3 }, - ), - ]); + { sequence: 2 }, + ), + makeEvent( + "thread.message-sent", + { + threadId: thread.id, + messageId: MessageId.makeUnsafe("assistant-1"), + role: "assistant", + text: "done", + turnId: TurnId.makeUnsafe("turn-1"), + streaming: false, + createdAt: "2026-02-27T00:00:03.000Z", + updatedAt: "2026-02-27T00:00:03.000Z", + }, + { sequence: 3 }, + ), + ], + localEnvironmentId, + ); expect(threadsOf(next)[0]?.session?.status).toBe("running"); expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed"); @@ -960,6 +803,7 @@ describe("incremental orchestration updates", () => { assistantMessageId: MessageId.makeUnsafe("assistant-1"), completedAt: "2026-02-27T00:00:04.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1); @@ -1004,6 +848,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:03.000Z", updatedAt: "2026-02-27T00:00:03.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( @@ -1113,6 +958,7 @@ describe("incremental orchestration updates", () => { threadId: ThreadId.makeUnsafe("thread-1"), turnCount: 1, }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([ @@ -1171,6 +1017,7 @@ describe("incremental orchestration updates", () => { threadId: thread.id, turnCount: 1, }), + localEnvironmentId, ); expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined(); @@ -1189,6 +1036,7 @@ describe("incremental orchestration updates", () => { updatedAt: "2026-02-27T00:00:04.000Z", }, }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index c7d1e72a8e..dec4587aba 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -385,7 +385,6 @@ function buildProposedPlanSlice(thread: Thread): { }; } -<<<<<<< HEAD function buildTurnDiffSlice(thread: Thread): { ids: TurnId[]; byId: Record; @@ -694,60 +693,6 @@ function removeThreadState(state: EnvironmentState, threadId: ThreadId): Environ turnDiffSummaryByThreadId, sidebarThreadSummaryById, }; -======= -function isScopedRecordKeyForEnvironment( - scopedRecordKey: string, - environmentId: EnvironmentId, - kind: "project" | "thread", -): boolean { - return scopedRecordKey.startsWith(`${environmentId}:${kind}:`); -} - -function mergeSidebarThreadsByScopedId( - current: AppState["sidebarThreadsByScopedId"], - environmentId: EnvironmentId, - threadsForEnvironment: ReadonlyArray, -): AppState["sidebarThreadsByScopedId"] { - const next = Object.fromEntries( - Object.entries(current).filter( - ([scopedId]) => !isScopedRecordKeyForEnvironment(scopedId, environmentId, "thread"), - ), - ); - - for (const thread of threadsForEnvironment) { - const scopedId = getThreadScopedId({ - environmentId: thread.environmentId, - id: thread.id, - }); - const nextSummary = buildSidebarThreadSummary(thread); - const previousSummary = current[scopedId]; - next[scopedId] = - sidebarThreadSummariesEqual(previousSummary, nextSummary) && previousSummary !== undefined - ? previousSummary - : nextSummary; - } - - return next; -} - -function mergeThreadScopedIdsByProjectScopedId( - current: AppState["threadScopedIdsByProjectScopedId"], - environmentId: EnvironmentId, - threadsForEnvironment: ReadonlyArray, -): AppState["threadScopedIdsByProjectScopedId"] { - const next = Object.fromEntries( - Object.entries(current).filter( - ([scopedId]) => !isScopedRecordKeyForEnvironment(scopedId, environmentId, "project"), - ), - ); - const nextEntries = buildThreadScopedIdsByProjectScopedId(threadsForEnvironment); - - for (const [scopedId, threadScopedIds] of Object.entries(nextEntries)) { - next[scopedId] = threadScopedIds; - } - - return next; ->>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) } function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { @@ -1075,7 +1020,53 @@ function getStoredEnvironmentState( state: AppState, environmentId: EnvironmentId, ): EnvironmentState { - return state.environmentStateById[environmentId] ?? initialEnvironmentState; + const storedEnvironmentState = state.environmentStateById[environmentId]; + if (state.activeEnvironmentId !== environmentId) { + return storedEnvironmentState ?? initialEnvironmentState; + } + + if ( + storedEnvironmentState && + storedEnvironmentState.projectIds === state.projectIds && + storedEnvironmentState.projectById === state.projectById && + storedEnvironmentState.threadIds === state.threadIds && + storedEnvironmentState.threadIdsByProjectId === state.threadIdsByProjectId && + storedEnvironmentState.threadShellById === state.threadShellById && + storedEnvironmentState.threadSessionById === state.threadSessionById && + storedEnvironmentState.threadTurnStateById === state.threadTurnStateById && + storedEnvironmentState.messageIdsByThreadId === state.messageIdsByThreadId && + storedEnvironmentState.messageByThreadId === state.messageByThreadId && + storedEnvironmentState.activityIdsByThreadId === state.activityIdsByThreadId && + storedEnvironmentState.activityByThreadId === state.activityByThreadId && + storedEnvironmentState.proposedPlanIdsByThreadId === state.proposedPlanIdsByThreadId && + storedEnvironmentState.proposedPlanByThreadId === state.proposedPlanByThreadId && + storedEnvironmentState.turnDiffIdsByThreadId === state.turnDiffIdsByThreadId && + storedEnvironmentState.turnDiffSummaryByThreadId === state.turnDiffSummaryByThreadId && + storedEnvironmentState.sidebarThreadSummaryById === state.sidebarThreadSummaryById && + storedEnvironmentState.bootstrapComplete === state.bootstrapComplete + ) { + return storedEnvironmentState; + } + + return { + projectIds: state.projectIds, + projectById: state.projectById, + threadIds: state.threadIds, + threadIdsByProjectId: state.threadIdsByProjectId, + threadShellById: state.threadShellById, + threadSessionById: state.threadSessionById, + threadTurnStateById: state.threadTurnStateById, + messageIdsByThreadId: state.messageIdsByThreadId, + messageByThreadId: state.messageByThreadId, + activityIdsByThreadId: state.activityIdsByThreadId, + activityByThreadId: state.activityByThreadId, + proposedPlanIdsByThreadId: state.proposedPlanIdsByThreadId, + proposedPlanByThreadId: state.proposedPlanByThreadId, + turnDiffIdsByThreadId: state.turnDiffIdsByThreadId, + turnDiffSummaryByThreadId: state.turnDiffSummaryByThreadId, + sidebarThreadSummaryById: state.sidebarThreadSummaryById, + bootstrapComplete: state.bootstrapComplete, + }; } function projectActiveEnvironmentState(input: { @@ -1142,46 +1133,15 @@ export function syncServerReadModel( readModel: OrchestrationReadModel, environmentId: EnvironmentId, ): AppState { -<<<<<<< HEAD return commitEnvironmentState( state, environmentId, - syncEnvironmentReadModel(getStoredEnvironmentState(state, environmentId), readModel, environmentId), - ); -======= - const projectsForEnvironment = readModel.projects - .filter((project) => project.deletedAt === null) - .map((project) => mapProject(project, environmentId)); - const threadsForEnvironment = readModel.threads - .filter((thread) => thread.deletedAt === null) - .map((thread) => mapThread(thread, environmentId)); - const projects = [ - ...state.projects.filter((project) => !sameEnvironmentId(project.environmentId, environmentId)), - ...projectsForEnvironment, - ]; - const threads = [ - ...state.threads.filter((thread) => !sameEnvironmentId(thread.environmentId, environmentId)), - ...threadsForEnvironment, - ]; - const sidebarThreadsByScopedId = mergeSidebarThreadsByScopedId( - state.sidebarThreadsByScopedId, - environmentId, - threadsForEnvironment, - ); - const threadScopedIdsByProjectScopedId = mergeThreadScopedIdsByProjectScopedId( - state.threadScopedIdsByProjectScopedId, - environmentId, - threadsForEnvironment, + syncEnvironmentReadModel( + getStoredEnvironmentState(state, environmentId), + readModel, + environmentId, + ), ); - return { - ...state, - projects, - threads, - sidebarThreadsByScopedId, - threadScopedIdsByProjectScopedId, - bootstrapComplete: true, - }; ->>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) } function applyEnvironmentOrchestrationEvent( @@ -1191,16 +1151,19 @@ function applyEnvironmentOrchestrationEvent( ): EnvironmentState { switch (event.type) { case "project.created": { - const nextProject = mapProject({ - id: event.payload.projectId, - title: event.payload.title, - workspaceRoot: event.payload.workspaceRoot, - defaultModelSelection: event.payload.defaultModelSelection, - scripts: event.payload.scripts, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - deletedAt: null, - }, environmentId); + const nextProject = mapProject( + { + id: event.payload.projectId, + title: event.payload.title, + workspaceRoot: event.payload.workspaceRoot, + defaultModelSelection: event.payload.defaultModelSelection, + scripts: event.payload.scripts, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + deletedAt: null, + }, + environmentId, + ); const existingProjectId = state.projectIds.find( (projectId) => @@ -1281,26 +1244,29 @@ function applyEnvironmentOrchestrationEvent( case "thread.created": { const previousThread = getThread(state, event.payload.threadId); - const nextThread = mapThread({ - id: event.payload.threadId, - projectId: event.payload.projectId, - title: event.payload.title, - modelSelection: event.payload.modelSelection, - runtimeMode: event.payload.runtimeMode, - interactionMode: event.payload.interactionMode, - branch: event.payload.branch, - worktreePath: event.payload.worktreePath, - latestTurn: null, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - archivedAt: null, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], - session: null, - }, environmentId); + const nextThread = mapThread( + { + id: event.payload.threadId, + projectId: event.payload.projectId, + title: event.payload.title, + modelSelection: event.payload.modelSelection, + runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, + branch: event.payload.branch, + worktreePath: event.payload.worktreePath, + latestTurn: null, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + environmentId, + ); return writeThreadState(state, nextThread, previousThread); } @@ -1689,7 +1655,6 @@ export const selectProjectById = export const selectThreadById = (threadId: ThreadId | null | undefined) => (state: AppState): Thread | undefined => -<<<<<<< HEAD threadId ? getThread(state, threadId) : undefined; export const selectSidebarThreadSummaryById = (threadId: ThreadId | null | undefined) => @@ -1699,53 +1664,6 @@ export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => (state: AppState): ThreadId[] => projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; -======= - threadId - ? state.threads.find( - (thread) => - thread.id === threadId && - sameEnvironmentId(thread.environmentId, state.activeEnvironmentId), - ) - : undefined; - -export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => { - let cachedProjectScopedId: string | null = null; - let cachedScopedIds: string[] | undefined; - let cachedSidebarThreadsByScopedId: Record | undefined; - let cachedResult: ThreadId[] = EMPTY_THREAD_IDS; - - return (state: AppState): ThreadId[] => { - if (!projectId) { - return EMPTY_THREAD_IDS; - } - - const projectScopedId = getProjectScopedId({ - environmentId: state.activeEnvironmentId, - id: projectId, - }); - const scopedIds = state.threadScopedIdsByProjectScopedId[projectScopedId] ?? EMPTY_SCOPED_IDS; - const sidebarThreadsByScopedId = state.sidebarThreadsByScopedId; - - if ( - cachedProjectScopedId === projectScopedId && - cachedScopedIds === scopedIds && - cachedSidebarThreadsByScopedId === sidebarThreadsByScopedId - ) { - return cachedResult; - } - - const result = scopedIds - .map((scopedId) => sidebarThreadsByScopedId[scopedId]?.id ?? null) - .filter((threadId): threadId is ThreadId => threadId !== null); - - cachedProjectScopedId = projectScopedId; - cachedScopedIds = scopedIds; - cachedSidebarThreadsByScopedId = sidebarThreadsByScopedId; - cachedResult = result; - return result; - }; -}; ->>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { if (state.activeEnvironmentId === null) { @@ -1779,10 +1697,7 @@ export function applyOrchestrationEvent( ); } -export function setActiveEnvironmentId( - state: AppState, - environmentId: EnvironmentId, -): AppState { +export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState { if (state.activeEnvironmentId === environmentId) { return state; } diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index be70a86b13..a7a7440eb2 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,4 +1,3 @@ -<<<<<<< HEAD import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts"; import { type AppState } from "./store"; import { @@ -11,12 +10,6 @@ import { type ThreadTurnState, type TurnDiffSummary, } from "./types"; -======= -import { type ThreadId } from "@t3tools/contracts"; -import { useMemo } from "react"; -import { selectProjectById, selectThreadById, useStore } from "./store"; -import { type Project, type Thread } from "./types"; ->>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) const EMPTY_MESSAGES: ChatMessage[] = []; const EMPTY_ACTIVITIES: Thread["activities"] = []; @@ -42,7 +35,6 @@ export function createProjectSelector( ): (state: AppState) => Project | undefined { return (state) => (projectId ? state.projectById[projectId] : undefined); } -<<<<<<< HEAD export function createSidebarThreadSummarySelector( threadId: ThreadId | null | undefined, @@ -152,5 +144,3 @@ export function createThreadSelector( return previousThread; }; } -======= ->>>>>>> 412c520d1 (Preserve remote scoped state across snapshot syncs) From 010f45280314153749c99f8bc9cd89730a4670e3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 17:19:57 -0700 Subject: [PATCH 18/48] Scope thread state by environment - Key web stores and routes by environment-aware thread refs - Update draft, terminal, and branch handling for scoped threads - Add tests for the new scoped selectors and routing --- apps/web/index.html | 1 + apps/web/src/components/BranchToolbar.tsx | 37 +- apps/web/src/components/ChatView.browser.tsx | 188 +- .../web/src/components/ChatView.logic.test.ts | 25 +- apps/web/src/components/ChatView.logic.ts | 21 +- apps/web/src/components/ChatView.tsx | 561 +++-- apps/web/src/components/DiffPanel.tsx | 31 +- .../components/GitActionsControl.browser.tsx | 19 +- apps/web/src/components/GitActionsControl.tsx | 14 +- .../components/KeybindingsToast.browser.tsx | 34 +- apps/web/src/components/Sidebar.tsx | 653 +++-- .../src/components/ThreadTerminalDrawer.tsx | 15 +- apps/web/src/components/chat/ChatHeader.tsx | 11 +- .../CompactComposerControlsMenu.browser.tsx | 62 +- .../components/chat/TraitsPicker.browser.tsx | 54 +- apps/web/src/components/chat/TraitsPicker.tsx | 14 +- .../chat/composerProviderRegistry.tsx | 53 +- .../components/settings/SettingsPanels.tsx | 59 +- apps/web/src/composerDraftStore.test.ts | 252 +- apps/web/src/composerDraftStore.ts | 2207 ++++++++++------- apps/web/src/hooks/useHandleNewThread.ts | 81 +- apps/web/src/hooks/useThreadActions.ts | 139 +- apps/web/src/lib/terminalStateCleanup.test.ts | 33 +- apps/web/src/lib/terminalStateCleanup.ts | 20 +- apps/web/src/routeTree.gen.ts | 39 +- apps/web/src/routes/__root.tsx | 87 +- ...tsx => _chat.$environmentId.$threadId.tsx} | 63 +- apps/web/src/routes/_chat.tsx | 28 +- apps/web/src/store.test.ts | 120 +- apps/web/src/store.ts | 189 +- apps/web/src/storeSelectors.ts | 95 +- apps/web/src/terminalStateStore.test.ts | 164 +- apps/web/src/terminalStateStore.ts | 287 ++- apps/web/src/threadRoutes.test.ts | 31 + apps/web/src/threadRoutes.ts | 22 + apps/web/src/threadSelectionStore.test.ts | 106 +- apps/web/src/threadSelectionStore.ts | 104 +- apps/web/src/uiStateStore.test.ts | 22 +- apps/web/src/uiStateStore.ts | 65 +- .../src/knownEnvironment.test.ts | 19 +- packages/client-runtime/src/scoped.ts | 41 + 41 files changed, 3594 insertions(+), 2472 deletions(-) rename apps/web/src/routes/{_chat.$threadId.tsx => _chat.$environmentId.$threadId.tsx} (79%) create mode 100644 apps/web/src/threadRoutes.test.ts create mode 100644 apps/web/src/threadRoutes.ts diff --git a/apps/web/index.html b/apps/web/index.html index 0322f2d019..45f30f7164 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -12,6 +12,7 @@ rel="stylesheet" /> T3 Code (Alpha) +
diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 92929f78fc..619f9efdca 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,11 +1,13 @@ -import type { ThreadId } from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { FolderIcon, GitForkIcon } from "lucide-react"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { newCommandId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useStore } from "../store"; +import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { EnvMode, resolveDraftEnvModeAfterBranchChange, @@ -20,6 +22,7 @@ const envModeItems = [ ] as const; interface BranchToolbarProps { + environmentId: EnvironmentId; threadId: ThreadId; onEnvModeChange: (mode: EnvMode) => void; envLocked: boolean; @@ -28,21 +31,30 @@ interface BranchToolbarProps { } export default function BranchToolbar({ + environmentId, threadId, onEnvModeChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarProps) { - const serverThread = useStore((store) => store.threadShellById[threadId]); - const serverSession = useStore((store) => store.threadSessionById[threadId] ?? null); + const threadRef = scopeThreadRef(environmentId, threadId); + const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); + const serverThread = useStore(serverThreadSelector); + const serverSession = serverThread?.session ?? null; const setThreadBranchAction = useStore((store) => store.setThreadBranch); - const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; - const activeProject = useStore((store) => - activeProjectId ? store.projectById[activeProjectId] : undefined, + const activeProjectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const activeProjectSelector = useMemo( + () => createProjectSelectorByRef(activeProjectRef), + [activeProjectRef], ); + const activeProject = useStore(activeProjectSelector); const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; @@ -56,7 +68,7 @@ export default function BranchToolbar({ const setThreadBranch = useCallback( (branch: string | null, worktreePath: string | null) => { - if (!activeThreadId) return; + if (!activeThreadId || !activeProject) return; const api = readNativeApi(); // If the effective cwd is about to change, stop the running session so the // next message creates a new one with the correct cwd. @@ -88,21 +100,24 @@ export default function BranchToolbar({ currentWorktreePath: activeWorktreePath, effectiveEnvMode, }); - setDraftThreadContext(threadId, { + setDraftThreadContext(threadRef, { branch, worktreePath, envMode: nextDraftEnvMode, + projectRef: scopeProjectRef(environmentId, activeProject.id), }); }, [ activeThreadId, + activeProject, serverSession, activeWorktreePath, hasServerThread, setThreadBranchAction, setDraftThreadContext, - threadId, + environmentId, effectiveEnvMode, + threadRef, ], ); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 1cdd7ec2e4..0646d899a9 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -17,6 +17,7 @@ import { OrchestrationSessionStatus, DEFAULT_SERVER_SETTINGS, } from "@t3tools/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; @@ -35,7 +36,7 @@ import { __resetNativeApiForTests } from "../nativeApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; -import { useStore } from "../store"; +import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; import { estimateTimelineMessageHeight } from "./timelineHeight"; @@ -49,8 +50,14 @@ vi.mock("../lib/gitStatusState", () => ({ })); const THREAD_ID = "thread-browser-test" as ThreadId; -const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_ID = "project-1" as ProjectId; +const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); +const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); +const THREAD_KEY = scopedThreadKey(THREAD_REF); +const UUID_ROUTE_RE = new RegExp( + `^/${LOCAL_ENVIRONMENT_ID}/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, +); +const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; @@ -433,11 +440,27 @@ async function waitForWsClient(): Promise { ); } +function threadRefFor(threadId: ThreadId) { + return scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); +} + +function threadKeyFor(threadId: ThreadId): string { + return scopedThreadKey(threadRefFor(threadId)); +} + +function threadIdFromPath(pathname: string): ThreadId { + const [, , threadId] = pathname.split("/"); + if (!threadId) { + throw new Error(`Expected scoped thread path, received "${pathname}".`); + } + return threadId as ThreadId; +} + async function waitForAppBootstrap(): Promise { await vi.waitFor( () => { expect(getServerConfig()).not.toBeNull(); - expect(useStore.getState().bootstrapComplete).toBe(true); + expect(selectBootstrapCompleteForActiveEnvironment(useStore.getState())).toBe(true); }, { timeout: 8_000, interval: 16 }, ); @@ -451,7 +474,9 @@ async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise { - expect(useComposerDraftStore.getState().draftThreadsByThreadId[threadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftThreadsByThreadKey[threadKeyFor(threadId)]).toBe( + undefined, + ); }, { timeout: 8_000, interval: 16 }, ); @@ -482,8 +507,9 @@ function withProjectScripts( function setDraftThreadWithoutWorktree(): void { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", @@ -493,8 +519,8 @@ function setDraftThreadWithoutWorktree(): void { envMode: "local", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + projectDraftThreadKeyByProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); } @@ -1063,7 +1089,7 @@ async function mountChatView(options: { const router = getRouter( createMemoryHistory({ - initialEntries: [`/${THREAD_ID}`], + initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], }), ); @@ -1175,35 +1201,20 @@ describe("ChatView timeline estimator parity (full app)", () => { wsRequests.length = 0; customWsRpcResolver = null; useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + projectDraftThreadKeyByProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); useStore.setState({ - projectIds: [], - projectById: {}, - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - sidebarThreadSummaryById: {}, - bootstrapComplete: false, + activeEnvironmentId: null, + environmentStateById: {}, }); useTerminalStateStore.persist.clearStorage(); useTerminalStateStore.setState({ - terminalStateByThreadId: {}, - terminalLaunchContextByThreadId: {}, + terminalStateByThreadKey: {}, + terminalLaunchContextByThreadKey: {}, terminalEventEntriesByKey: {}, nextTerminalEventId: 1, }); @@ -1444,8 +1455,8 @@ describe("ChatView timeline estimator parity (full app)", () => { } useTerminalStateStore.setState({ - terminalStateByThreadId: { - [THREAD_ID]: { + terminalStateByThreadKey: { + [THREAD_KEY]: { terminalOpen: true, terminalHeight: 280, terminalIds: ["default"], @@ -1455,8 +1466,8 @@ describe("ChatView timeline estimator parity (full app)", () => { activeTerminalGroupId: "group-default", }, }, - terminalLaunchContextByThreadId: { - [THREAD_ID]: { + terminalLaunchContextByThreadKey: { + [THREAD_KEY]: { cwd: "/repo/project", worktreePath: null, }, @@ -1702,8 +1713,9 @@ describe("ChatView timeline estimator parity (full app)", () => { it("runs project scripts from local draft threads at the project cwd", async () => { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", @@ -1713,8 +1725,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "local", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + projectDraftThreadKeyByProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1778,8 +1790,9 @@ describe("ChatView timeline estimator parity (full app)", () => { it("runs project scripts from worktree draft threads at the worktree cwd", async () => { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", @@ -1789,8 +1802,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "worktree", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + projectDraftThreadKeyByProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1841,8 +1854,9 @@ describe("ChatView timeline estimator parity (full app)", () => { it("lets the server own setup after preparing a pull request worktree thread", async () => { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", @@ -1852,8 +1866,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "local", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + projectDraftThreadKeyByProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1963,11 +1977,12 @@ describe("ChatView timeline estimator parity (full app)", () => { it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { useTerminalStateStore.setState({ - terminalStateByThreadId: {}, + terminalStateByThreadKey: {}, }); useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", @@ -1977,8 +1992,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "worktree", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + projectDraftThreadKeyByProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -2004,7 +2019,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); await waitForLayout(); const sendButton = await waitForSendButton(); @@ -2063,11 +2078,12 @@ describe("ChatView timeline estimator parity (full app)", () => { it("shows the send state once bootstrap dispatch is in flight", async () => { useTerminalStateStore.setState({ - terminalStateByThreadId: {}, + terminalStateByThreadKey: {}, }); useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", @@ -2077,8 +2093,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "worktree", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + projectDraftThreadKeyByProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -2107,7 +2123,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); await waitForLayout(); const sendButton = await waitForSendButton(); @@ -2199,7 +2215,7 @@ describe("ChatView timeline estimator parity (full app)", () => { const removedLabel = "Terminal 1 lines 1-2"; const addedLabel = "Terminal 2 lines 9-10"; useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-removed", terminalLabel: "Terminal 1", @@ -2226,21 +2242,21 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const store = useComposerDraftStore.getState(); - const currentPrompt = store.draftsByThreadId[THREAD_ID]?.prompt ?? ""; + const currentPrompt = store.draftsByThreadKey[THREAD_KEY]?.prompt ?? ""; const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); - store.setPrompt(THREAD_ID, nextPrompt.prompt); - store.removeTerminalContext(THREAD_ID, "ctx-removed"); + store.setPrompt(THREAD_REF, nextPrompt.prompt); + store.removeTerminalContext(THREAD_REF, "ctx-removed"); await vi.waitFor( () => { - expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]).toBeUndefined(); expect(document.body.textContent).not.toContain(removedLabel); }, { timeout: 8_000, interval: 16 }, ); useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-added", terminalLabel: "Terminal 2", @@ -2252,7 +2268,7 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( () => { - const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]; + const draft = useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]; expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); expect(document.body.textContent).toContain(addedLabel); expect(document.body.textContent).not.toContain(removedLabel); @@ -2267,7 +2283,7 @@ describe("ChatView timeline estimator parity (full app)", () => { it("disables send when the composer only contains an expired terminal pill", async () => { const expiredLabel = "Terminal 1 line 4"; useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-expired-only", terminalLabel: "Terminal 1", @@ -2303,7 +2319,7 @@ describe("ChatView timeline estimator parity (full app)", () => { it("warns when sending text while omitting expired terminal pills", async () => { const expiredLabel = "Terminal 1 line 4"; useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-expired-send-warning", terminalLabel: "Terminal 1", @@ -2314,7 +2330,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ); useComposerDraftStore .getState() - .setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); + .setPrompt(THREAD_REF, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -2476,7 +2492,7 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newThreadId = threadIdFromPath(newThreadPath); // The composer editor should be present for the new draft thread. await waitForComposerEditor(); @@ -2537,9 +2553,11 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newThreadId = threadIdFromPath(newThreadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + expect( + useComposerDraftStore.getState().draftsByThreadKey[threadKeyFor(newThreadId)], + ).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", @@ -2590,9 +2608,11 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new sticky claude draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newThreadId = threadIdFromPath(newThreadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + expect( + useComposerDraftStore.getState().draftsByThreadKey[threadKeyFor(newThreadId)], + ).toMatchObject({ modelSelectionByProvider: { claudeAgent: { provider: "claudeAgent", @@ -2630,9 +2650,11 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newThreadId = threadIdFromPath(newThreadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftsByThreadKey[threadKeyFor(newThreadId)]).toBe( + undefined, + ); } finally { await mounted.cleanup(); } @@ -2672,9 +2694,11 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a sticky draft thread UUID.", ); - const threadId = threadPath.slice(1) as ThreadId; + const threadId = threadIdFromPath(threadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + expect( + useComposerDraftStore.getState().draftsByThreadKey[threadKeyFor(threadId)], + ).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", @@ -2687,7 +2711,7 @@ describe("ChatView timeline estimator parity (full app)", () => { activeProvider: "codex", }); - useComposerDraftStore.getState().setModelSelection(threadId, { + useComposerDraftStore.getState().setModelSelection(threadRefFor(threadId), { provider: "codex", model: "gpt-5.4", options: { @@ -2703,7 +2727,9 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => path === threadPath, "New-thread should reuse the existing project draft thread.", ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + expect( + useComposerDraftStore.getState().draftsByThreadKey[threadKeyFor(threadId)], + ).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", @@ -2810,7 +2836,7 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a promoted draft thread UUID.", ); - const promotedThreadId = promotedThreadPath.slice(1) as ThreadId; + const promotedThreadId = threadIdFromPath(promotedThreadPath); await promoteDraftThreadViaDomainEvent(promotedThreadId); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 184a1fb11c..0a134da9c8 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,6 +1,7 @@ +import { scopeThreadRef } from "@t3tools/client-runtime"; import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { useStore } from "../store"; +import { type EnvironmentState, useStore } from "../store"; import { type Thread } from "../types"; import { @@ -211,8 +212,7 @@ const makeThread = (input?: { function setStoreThreads(threads: ReadonlyArray>) { const projectId = ProjectId.makeUnsafe("project-1"); - useStore.setState({ - activeEnvironmentId: localEnvironmentId, + const environmentState: EnvironmentState = { projectIds: [projectId], projectById: { [projectId]: { @@ -307,7 +307,12 @@ function setStoreThreads(threads: ReadonlyArray>) ), sidebarThreadSummaryById: {}, bootstrapComplete: true, - environmentStateById: {}, + }; + useStore.setState({ + activeEnvironmentId: localEnvironmentId, + environmentStateById: { + [localEnvironmentId]: environmentState, + }, }); } @@ -333,14 +338,16 @@ describe("waitForStartedServerThread", () => { }), ]); - await expect(waitForStartedServerThread(threadId)).resolves.toBe(true); + await expect( + waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId)), + ).resolves.toBe(true); }); it("waits for the thread to start via subscription updates", async () => { const threadId = ThreadId.makeUnsafe("thread-wait"); setStoreThreads([makeThread({ id: threadId })]); - const promise = waitForStartedServerThread(threadId, 500); + const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); setStoreThreads([ makeThread({ @@ -383,7 +390,9 @@ describe("waitForStartedServerThread", () => { return originalSubscribe(listener); }); - await expect(waitForStartedServerThread(threadId, 500)).resolves.toBe(true); + await expect( + waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500), + ).resolves.toBe(true); }); it("returns false after the timeout when the thread never starts", async () => { @@ -391,7 +400,7 @@ describe("waitForStartedServerThread", () => { const threadId = ThreadId.makeUnsafe("thread-timeout"); setStoreThreads([makeThread({ id: threadId })]); - const promise = waitForStartedServerThread(threadId, 500); + const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); await vi.advanceTimersByTimeAsync(500); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 3818613fbe..2e70e4e28b 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,7 +1,7 @@ import { - type EnvironmentId, ProjectId, type ModelSelection, + type ScopedThreadRef, type ThreadId, type TurnId, } from "@t3tools/contracts"; @@ -9,7 +9,7 @@ import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import { Schema } from "effect"; -import { selectThreadById, useStore } from "../store"; +import { selectThreadByRef, useStore } from "../store"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, @@ -24,14 +24,13 @@ export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema. export function buildLocalDraftThread( threadId: ThreadId, - environmentId: EnvironmentId, draftThread: DraftThreadState, fallbackModelSelection: ModelSelection, error: string | null, ): Thread { return { id: threadId, - environmentId, + environmentId: draftThread.environmentId, codexThreadId: null, projectId: draftThread.projectId, title: "New thread", @@ -53,12 +52,12 @@ export function buildLocalDraftThread( } export function reconcileMountedTerminalThreadIds(input: { - currentThreadIds: ReadonlyArray; - openThreadIds: ReadonlyArray; - activeThreadId: ThreadId | null; + currentThreadIds: ReadonlyArray; + openThreadIds: ReadonlyArray; + activeThreadId: string | null; activeThreadTerminalOpen: boolean; maxHiddenThreadCount?: number; -}): ThreadId[] { +}): string[] { const openThreadIdSet = new Set(input.openThreadIds); const hiddenThreadIds = input.currentThreadIds.filter( (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), @@ -207,10 +206,10 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { } export async function waitForStartedServerThread( - threadId: ThreadId, + threadRef: ScopedThreadRef, timeoutMs = 1_000, ): Promise { - const getThread = () => selectThreadById(threadId)(useStore.getState()); + const getThread = () => selectThreadByRef(useStore.getState(), threadRef); const thread = getThread(); if (threadHasStarted(thread)) { @@ -233,7 +232,7 @@ export async function waitForStartedServerThread( }; const unsubscribe = useStore.subscribe((state) => { - if (!threadHasStarted(selectThreadById(threadId)(state))) { + if (!threadHasStarted(selectThreadByRef(state, threadRef))) { return; } finish(true); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f6bdcf1117..64646d5573 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,6 +2,7 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, + type EnvironmentId, type MessageId, type ModelSelection, type ProjectScript, @@ -20,6 +21,12 @@ import { RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; +import { + parseScopedThreadKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; @@ -27,6 +34,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { isElectron } from "../env"; @@ -63,8 +71,8 @@ import { setPendingUserInputCustomAnswer, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { useStore } from "../store"; -import { createThreadSelector } from "../storeSelectors"; +import { selectThreadsAcrossEnvironments, useStore } from "../store"; +import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, @@ -202,6 +210,7 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES 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_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; @@ -209,26 +218,115 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record; function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { - const threadShellById = useStore((state) => state.threadShellById); - const proposedPlanIdsByThreadId = useStore((state) => state.proposedPlanIdsByThreadId); - const proposedPlanByThreadId = useStore((state) => state.proposedPlanByThreadId); + return useStore( + useMemo(() => { + let previousThreadIds: readonly ThreadId[] = []; + let previousResult: ThreadPlanCatalogEntry[] = []; + let previousEntries = new Map< + ThreadId, + { + shell: object | null; + proposedPlanIds: readonly string[] | undefined; + proposedPlansById: Record | undefined; + entry: ThreadPlanCatalogEntry; + } + >(); + + return (state) => { + const sameThreadIds = + previousThreadIds.length === threadIds.length && + previousThreadIds.every((id, index) => id === threadIds[index]); + const nextEntries = new Map< + ThreadId, + { + shell: object | null; + proposedPlanIds: readonly string[] | undefined; + proposedPlansById: Record | undefined; + entry: ThreadPlanCatalogEntry; + } + >(); + const nextResult: ThreadPlanCatalogEntry[] = []; + let changed = !sameThreadIds; + + for (const threadId of threadIds) { + let shell: object | undefined; + let proposedPlanIds: readonly string[] | undefined; + let proposedPlansById: Record | undefined; + + for (const environmentState of Object.values(state.environmentStateById)) { + const matchedShell = environmentState.threadShellById[threadId]; + if (!matchedShell) { + continue; + } + shell = matchedShell; + proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; + proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as + | Record + | undefined; + break; + } - return useMemo( - () => - threadIds.flatMap((threadId) => { - if (!threadShellById[threadId]) { - return []; + if (!shell) { + const previous = previousEntries.get(threadId); + if ( + previous && + previous.shell === null && + previous.proposedPlanIds === undefined && + previous.proposedPlansById === undefined + ) { + nextEntries.set(threadId, previous); + continue; + } + changed = true; + nextEntries.set(threadId, { + shell: null, + proposedPlanIds: undefined, + proposedPlansById: undefined, + entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS }, + }); + continue; + } + + const previous = previousEntries.get(threadId); + if ( + previous && + previous.shell === shell && + previous.proposedPlanIds === proposedPlanIds && + previous.proposedPlansById === proposedPlansById + ) { + nextEntries.set(threadId, previous); + nextResult.push(previous.entry); + continue; + } + + changed = true; + const proposedPlans = + proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById + ? proposedPlanIds.flatMap((planId) => { + const proposedPlan = proposedPlansById?.[planId]; + return proposedPlan ? [proposedPlan] : []; + }) + : EMPTY_PROPOSED_PLANS; + const entry = { id: threadId, proposedPlans }; + nextEntries.set(threadId, { + shell, + proposedPlanIds, + proposedPlansById, + entry, + }); + nextResult.push(entry); } - const proposedPlans = - proposedPlanIdsByThreadId[threadId]?.flatMap((planId) => { - const plan = proposedPlanByThreadId[threadId]?.[planId]; - return plan ? [plan] : []; - }) ?? []; + if (!changed && previousResult.length === nextResult.length) { + return previousResult; + } - return [{ id: threadId, proposedPlans }]; - }), - [proposedPlanByThreadId, proposedPlanIdsByThreadId, threadIds, threadShellById], + previousThreadIds = threadIds; + previousEntries = nextEntries; + previousResult = nextResult; + return nextResult; + }; + }, [threadIds]), ); } @@ -278,6 +376,7 @@ const terminalContextIdListsEqual = ( contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); interface ChatViewProps { + environmentId: EnvironmentId; threadId: ThreadId; } @@ -357,6 +456,7 @@ function useLocalDispatchState(input: { } interface PersistentThreadTerminalDrawerProps { + threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; visible: boolean; launchContext: PersistentTerminalLaunchContext | null; @@ -368,6 +468,7 @@ interface PersistentThreadTerminalDrawerProps { } function PersistentThreadTerminalDrawer({ + threadRef, threadId, visible, launchContext, @@ -377,19 +478,16 @@ function PersistentThreadTerminalDrawer({ closeShortcutLabel, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { - const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId])); - const draftThread = useComposerDraftStore( - (store) => store.draftThreadsByThreadId[threadId] ?? null, - ); - const project = useStore((state) => - serverThread?.projectId - ? state.projectById[serverThread.projectId] - : draftThread?.projectId - ? state.projectById[draftThread.projectId] - : undefined, - ); + const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); const terminalState = useTerminalStateStore((state) => - selectThreadTerminalState(state.terminalStateByThreadId, threadId), + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef), ); const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); @@ -435,27 +533,27 @@ function PersistentThreadTerminalDrawer({ const setTerminalHeight = useCallback( (height: number) => { - storeSetTerminalHeight(threadId, height); + storeSetTerminalHeight(threadRef, height); }, - [storeSetTerminalHeight, threadId], + [storeSetTerminalHeight, threadRef], ); const splitTerminal = useCallback(() => { - storeSplitTerminal(threadId, `terminal-${randomUUID()}`); + storeSplitTerminal(threadRef, `terminal-${randomUUID()}`); bumpFocusRequestId(); - }, [bumpFocusRequestId, storeSplitTerminal, threadId]); + }, [bumpFocusRequestId, storeSplitTerminal, threadRef]); const createNewTerminal = useCallback(() => { - storeNewTerminal(threadId, `terminal-${randomUUID()}`); + storeNewTerminal(threadRef, `terminal-${randomUUID()}`); bumpFocusRequestId(); - }, [bumpFocusRequestId, storeNewTerminal, threadId]); + }, [bumpFocusRequestId, storeNewTerminal, threadRef]); const activateTerminal = useCallback( (terminalId: string) => { - storeSetActiveTerminal(threadId, terminalId); + storeSetActiveTerminal(threadRef, terminalId); bumpFocusRequestId(); }, - [bumpFocusRequestId, storeSetActiveTerminal, threadId], + [bumpFocusRequestId, storeSetActiveTerminal, threadRef], ); const closeTerminal = useCallback( @@ -481,10 +579,10 @@ function PersistentThreadTerminalDrawer({ void fallbackExitWrite(); } - storeCloseTerminal(threadId, terminalId); + storeCloseTerminal(threadRef, terminalId); bumpFocusRequestId(); }, - [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId], + [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId, threadRef], ); const handleAddTerminalContext = useCallback( @@ -504,6 +602,7 @@ function PersistentThreadTerminalDrawer({ return (
createThreadSelector(threadId), [threadId])); +export default function ChatView({ environmentId, threadId }: ChatViewProps) { + const routeThreadRef = useMemo( + () => scopeThreadRef(environmentId, threadId), + [environmentId, threadId], + ); + const serverThread = useStore( + useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), + ); const setStoreThreadError = useStore((store) => store.setError); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[threadId], + (store) => + store.threadLastVisitedAtById[scopedThreadKey(scopeThreadRef(environmentId, threadId))], ); const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( @@ -547,7 +653,7 @@ export default function ChatView({ threadId }: ChatViewProps) { select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); - const composerDraft = useComposerThreadDraft(threadId); + const composerDraft = useComposerThreadDraft(routeThreadRef); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; @@ -590,17 +696,15 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const getDraftThreadByProjectId = useComposerDraftStore( - (store) => store.getDraftThreadByProjectId, + const getDraftThreadByProjectRef = useComposerDraftStore( + (store) => store.getDraftThreadByProjectRef, ); - const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); + const getDraftThreadByRef = useComposerDraftStore((store) => store.getDraftThreadByRef); const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); - const draftThread = useComposerDraftStore( - (store) => store.draftThreadsByThreadId[threadId] ?? null, - ); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(routeThreadRef)); const promptRef = useRef(prompt); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isDragOverComposer, setIsDragOverComposer] = useState(false); @@ -688,66 +792,72 @@ export default function ChatView({ threadId }: ChatViewProps) { setMessagesScrollElement(element); }, []); - const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); + const terminalStateByThreadKey = useTerminalStateStore((state) => state.terminalStateByThreadKey); const terminalState = useMemo( - () => selectThreadTerminalState(terminalStateByThreadId, threadId), - [terminalStateByThreadId, threadId], + () => selectThreadTerminalState(terminalStateByThreadKey, routeThreadRef), + [routeThreadRef, terminalStateByThreadKey], ); - const openTerminalThreadIds = useMemo( + const openTerminalThreadKeys = useMemo( () => - Object.entries(terminalStateByThreadId).flatMap(([nextThreadId, nextTerminalState]) => - nextTerminalState.terminalOpen ? [nextThreadId as ThreadId] : [], + Object.entries(terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) => + nextTerminalState.terminalOpen ? [nextThreadKey] : [], ), - [terminalStateByThreadId], + [terminalStateByThreadKey], ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); - const serverThreadIds = useStore((state) => state.threadIds); + const serverThreadKeys = useStore( + useShallow((state) => + selectThreadsAcrossEnvironments(state).map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + ); const storeServerTerminalLaunchContext = useTerminalStateStore( - (s) => s.terminalLaunchContextByThreadId[threadId] ?? null, + (s) => s.terminalLaunchContextByThreadKey[scopedThreadKey(routeThreadRef)] ?? null, ); const storeClearTerminalLaunchContext = useTerminalStateStore( (s) => s.clearTerminalLaunchContext, ); - const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); - const draftThreadIds = useMemo( - () => Object.keys(draftThreadsByThreadId) as ThreadId[], - [draftThreadsByThreadId], + const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); + const draftThreadKeys = useMemo( + () => Object.keys(draftThreadsByThreadKey), + [draftThreadsByThreadKey], ); - const [mountedTerminalThreadIds, setMountedTerminalThreadIds] = useState([]); + const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); const setPrompt = useCallback( (nextPrompt: string) => { - setComposerDraftPrompt(threadId, nextPrompt); + setComposerDraftPrompt(routeThreadRef, nextPrompt); }, - [setComposerDraftPrompt, threadId], + [routeThreadRef, setComposerDraftPrompt], ); const addComposerImage = useCallback( (image: ComposerImageAttachment) => { - addComposerDraftImage(threadId, image); + addComposerDraftImage(routeThreadRef, image); }, - [addComposerDraftImage, threadId], + [addComposerDraftImage, routeThreadRef], ); const addComposerImagesToDraft = useCallback( (images: ComposerImageAttachment[]) => { - addComposerDraftImages(threadId, images); + addComposerDraftImages(routeThreadRef, images); }, - [addComposerDraftImages, threadId], + [addComposerDraftImages, routeThreadRef], ); const addComposerTerminalContextsToDraft = useCallback( (contexts: TerminalContextDraft[]) => { - addComposerDraftTerminalContexts(threadId, contexts); + addComposerDraftTerminalContexts(routeThreadRef, contexts); }, - [addComposerDraftTerminalContexts, threadId], + [addComposerDraftTerminalContexts, routeThreadRef], ); const removeComposerImageFromDraft = useCallback( (imageId: string) => { - removeComposerDraftImage(threadId, imageId); + removeComposerDraftImage(routeThreadRef, imageId); }, - [removeComposerDraftImage, threadId], + [removeComposerDraftImage, routeThreadRef], ); const removeComposerTerminalContextFromDraft = useCallback( (contextId: string) => { @@ -760,7 +870,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); promptRef.current = nextPrompt.prompt; setPrompt(nextPrompt.prompt); - removeComposerDraftTerminalContext(threadId, contextId); + removeComposerDraftTerminalContext(routeThreadRef, contextId); setComposerCursor(nextPrompt.cursor); setComposerTrigger( detectComposerTrigger( @@ -769,20 +879,21 @@ export default function ChatView({ threadId }: ChatViewProps) { ), ); }, - [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], + [composerTerminalContexts, removeComposerDraftTerminalContext, routeThreadRef, setPrompt], ); - const fallbackDraftProject = useStore((state) => - draftThread?.projectId ? state.projectById[draftThread.projectId] : undefined, + const fallbackDraftProjectRef = draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const fallbackDraftProject = useStore( + useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]), ); - const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => - draftThread && activeEnvironmentId + draftThread ? buildLocalDraftThread( threadId, - activeEnvironmentId, draftThread, fallbackDraftProject?.defaultModelSelection ?? { provider: "codex", @@ -791,13 +902,7 @@ export default function ChatView({ threadId }: ChatViewProps) { localDraftError, ) : undefined, - [ - activeEnvironmentId, - draftThread, - fallbackDraftProject?.defaultModelSelection, - localDraftError, - threadId, - ], + [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; const runtimeMode = @@ -809,10 +914,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; - const existingOpenTerminalThreadIds = useMemo(() => { - const existingThreadIds = new Set([...serverThreadIds, ...draftThreadIds]); - return openTerminalThreadIds.filter((nextThreadId) => existingThreadIds.has(nextThreadId)); - }, [draftThreadIds, openTerminalThreadIds, serverThreadIds]); + const activeThreadRef = useMemo( + () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), + [activeThread], + ); + const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const existingOpenTerminalThreadKeys = useMemo(() => { + 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(() => { @@ -832,12 +942,12 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThread?.activities], ); useEffect(() => { - setMountedTerminalThreadIds((currentThreadIds) => { + setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ currentThreadIds, - openThreadIds: existingOpenTerminalThreadIds, - activeThreadId, - activeThreadTerminalOpen: Boolean(activeThreadId && terminalState.terminalOpen), + openThreadIds: existingOpenTerminalThreadKeys, + activeThreadId: activeThreadKey, + activeThreadTerminalOpen: Boolean(activeThreadKey && terminalState.terminalOpen), maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, }); return currentThreadIds.length === nextThreadIds.length && @@ -845,10 +955,13 @@ export default function ChatView({ threadId }: ChatViewProps) { ? currentThreadIds : nextThreadIds; }); - }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]); + }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProject = useStore((state) => - activeThread?.projectId ? state.projectById[activeThread.projectId] : undefined, + const activeProjectRef = activeThread + ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) + : null; + const activeProject = useStore( + useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), ); const openPullRequestDialog = useCallback( @@ -874,47 +987,56 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activeProject) { throw new Error("No active project is available for this pull request."); } - const storedDraftThread = getDraftThreadByProjectId(activeProject.id); + const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); + const storedDraftThread = getDraftThreadByProjectRef(activeProjectRef); if (storedDraftThread) { - setDraftThreadContext(storedDraftThread.threadId, input); - setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input); + setDraftThreadContext(storedDraftThread.threadRef, input); + setProjectDraftThreadId(activeProjectRef, storedDraftThread.threadRef, input); if (storedDraftThread.threadId !== threadId) { await navigate({ - to: "/$threadId", - params: { threadId: storedDraftThread.threadId }, + to: "/$environmentId/$threadId", + params: { + environmentId: activeProject.environmentId, + threadId: storedDraftThread.threadId, + }, }); } return storedDraftThread.threadId; } - const activeDraftThread = getDraftThread(threadId); + const activeDraftThread = getDraftThreadByRef(routeThreadRef); if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { - setDraftThreadContext(threadId, input); - setProjectDraftThreadId(activeProject.id, threadId, input); + setDraftThreadContext(routeThreadRef, input); + setProjectDraftThreadId(activeProjectRef, routeThreadRef, input); return threadId; } - clearProjectDraftThreadId(activeProject.id); + clearProjectDraftThreadId(activeProjectRef); const nextThreadId = newThreadId(); - setProjectDraftThreadId(activeProject.id, nextThreadId, { + const nextThreadRef = scopeThreadRef(activeProject.environmentId, nextThreadId); + setProjectDraftThreadId(activeProjectRef, nextThreadRef, { createdAt: new Date().toISOString(), runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); await navigate({ - to: "/$threadId", - params: { threadId: nextThreadId }, + to: "/$environmentId/$threadId", + params: { + environmentId: activeProject.environmentId, + threadId: nextThreadId, + }, }); return nextThreadId; }, [ activeProject, clearProjectDraftThreadId, - getDraftThread, - getDraftThreadByProjectId, + getDraftThreadByRef, + getDraftThreadByProjectRef, isServerThread, navigate, + routeThreadRef, setDraftThreadContext, setProjectDraftThreadId, threadId, @@ -941,12 +1063,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; - markThreadVisited(serverThread.id); + markThreadVisited(scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id))); }, [ activeLatestTurn?.completedAt, activeThreadLastVisitedAt, latestTurnSettled, markThreadVisited, + serverThread?.environmentId, serverThread?.id, ]); @@ -966,7 +1089,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ - threadId, + threadRef: routeThreadRef, providers: providerStatuses, selectedProvider, threadModelSelection: activeThread?.modelSelection, @@ -1539,15 +1662,18 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const onToggleDiff = useCallback(() => { void navigate({ - to: "/$threadId", - params: { threadId }, + to: "/$environmentId/$threadId", + params: { + environmentId, + threadId, + }, replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; }, }); - }, [diffOpen, navigate, threadId]); + }, [diffOpen, environmentId, navigate, threadId]); const envLocked = Boolean( activeThread && @@ -1568,7 +1694,12 @@ export default function ChatView({ threadId }: ChatViewProps) { (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; const nextError = sanitizeThreadErrorMessage(error); - if (useStore.getState().threadShellById[targetThreadId] !== undefined) { + const isCurrentServerThread = + activeThread !== undefined && + targetThreadId === routeThreadRef.threadId && + activeThread.environmentId === routeThreadRef.environmentId && + activeThread.id === routeThreadRef.threadId; + if (isCurrentServerThread) { setStoreThreadError(targetThreadId, nextError); return; } @@ -1582,7 +1713,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }); }, - [setStoreThreadError], + [activeThread, routeThreadRef, setStoreThreadError], ); const focusComposer = useCallback(() => { @@ -1613,7 +1744,7 @@ export default function ChatView({ threadId }: ChatViewProps) { insertion.cursor, ); const inserted = insertComposerDraftTerminalContext( - activeThread.id, + scopeThreadRef(activeThread.environmentId, activeThread.id), insertion.prompt, { id: randomUUID(), @@ -1637,27 +1768,27 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const setTerminalOpen = useCallback( (open: boolean) => { - if (!activeThreadId) return; - storeSetTerminalOpen(activeThreadId, open); + if (!activeThreadRef) return; + storeSetTerminalOpen(activeThreadRef, open); }, - [activeThreadId, storeSetTerminalOpen], + [activeThreadRef, storeSetTerminalOpen], ); const toggleTerminalVisibility = useCallback(() => { - if (!activeThreadId) return; + if (!activeThreadRef) return; setTerminalOpen(!terminalState.terminalOpen); - }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); + }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]); const splitTerminal = useCallback(() => { - if (!activeThreadId || hasReachedSplitLimit) return; + if (!activeThreadRef || hasReachedSplitLimit) return; const terminalId = `terminal-${randomUUID()}`; - storeSplitTerminal(activeThreadId, terminalId); + storeSplitTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]); + }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]); const createNewTerminal = useCallback(() => { - if (!activeThreadId) return; + if (!activeThreadRef) return; const terminalId = `terminal-${randomUUID()}`; - storeNewTerminal(activeThreadId, terminalId); + storeNewTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, storeNewTerminal]); + }, [activeThreadRef, storeNewTerminal]); const closeTerminal = useCallback( (terminalId: string) => { const api = readNativeApi(); @@ -1683,10 +1814,12 @@ export default function ChatView({ threadId }: ChatViewProps) { } else { void fallbackExitWrite(); } - storeCloseTerminal(activeThreadId, terminalId); + if (activeThreadRef) { + storeCloseTerminal(activeThreadRef, terminalId); + } setTerminalFocusRequestId((value) => value + 1); }, - [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length], + [activeThreadId, activeThreadRef, storeCloseTerminal, terminalState.terminalIds.length], ); const runProjectScript = useCallback( async ( @@ -1726,10 +1859,13 @@ export default function ChatView({ threadId }: ChatViewProps) { worktreePath: targetWorktreePath, }); setTerminalOpen(true); + if (!activeThreadRef) { + return; + } if (shouldCreateNewTerminal) { - storeNewTerminal(activeThreadId, targetTerminalId); + storeNewTerminal(activeThreadRef, targetTerminalId); } else { - storeSetActiveTerminal(activeThreadId, targetTerminalId); + storeSetActiveTerminal(activeThreadRef, targetTerminalId); } setTerminalFocusRequestId((value) => value + 1); @@ -1776,6 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProject, activeThread, activeThreadId, + activeThreadRef, gitCwd, setTerminalOpen, setThreadError, @@ -1920,9 +2057,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const handleRuntimeModeChange = useCallback( (mode: RuntimeMode) => { if (mode === runtimeMode) return; - setComposerDraftRuntimeMode(threadId, mode); + setComposerDraftRuntimeMode(routeThreadRef, mode); if (isLocalDraftThread) { - setDraftThreadContext(threadId, { runtimeMode: mode }); + setDraftThreadContext(routeThreadRef, { runtimeMode: mode }); } scheduleComposerFocus(); }, @@ -1932,16 +2069,16 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftRuntimeMode, setDraftThreadContext, - threadId, + routeThreadRef, ], ); const handleInteractionModeChange = useCallback( (mode: ProviderInteractionMode) => { if (mode === interactionMode) return; - setComposerDraftInteractionMode(threadId, mode); + setComposerDraftInteractionMode(routeThreadRef, mode); if (isLocalDraftThread) { - setDraftThreadContext(threadId, { interactionMode: mode }); + setDraftThreadContext(routeThreadRef, { interactionMode: mode }); } scheduleComposerFocus(); }, @@ -1951,7 +2088,7 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftInteractionMode, setDraftThreadContext, - threadId, + routeThreadRef, ], ); const toggleInteractionMode = useCallback(() => { @@ -2363,11 +2500,12 @@ export default function ChatView({ threadId }: ChatViewProps) { let cancelled = false; void (async () => { if (composerImages.length === 0) { - clearComposerDraftPersistedAttachments(threadId); + clearComposerDraftPersistedAttachments(routeThreadRef); return; } const getPersistedAttachmentsForThread = () => - useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; + useComposerDraftStore.getState().draftsByThreadKey[scopedThreadKey(routeThreadRef)] + ?.persistedAttachments ?? []; try { const currentPersistedAttachments = getPersistedAttachmentsForThread(); const existingPersistedById = new Map( @@ -2398,7 +2536,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } // Stage attachments in persisted draft state first so persist middleware can write them. - syncComposerDraftPersistedAttachments(threadId, serialized); + syncComposerDraftPersistedAttachments(routeThreadRef, serialized); } catch { const currentImageIds = new Set(composerImages.map((image) => image.id)); const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); @@ -2412,7 +2550,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (cancelled) { return; } - syncComposerDraftPersistedAttachments(threadId, fallbackAttachments); + syncComposerDraftPersistedAttachments(routeThreadRef, fallbackAttachments); } })(); return () => { @@ -2421,8 +2559,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [ clearComposerDraftPersistedAttachments, composerImages, + routeThreadRef, syncComposerDraftPersistedAttachments, - threadId, ]); const closeExpandedImage = useCallback(() => { @@ -2483,7 +2621,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { if (!activeThreadId) { setTerminalLaunchContext(null); - storeClearTerminalLaunchContext(threadId); + storeClearTerminalLaunchContext(routeThreadRef); return; } setTerminalLaunchContext((current) => { @@ -2491,7 +2629,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (current.threadId === activeThreadId) return current; return null; }); - }, [activeThreadId, storeClearTerminalLaunchContext, threadId]); + }, [activeThreadId, routeThreadRef, storeClearTerminalLaunchContext]); useEffect(() => { if (!activeThreadId || !activeProjectCwd) { @@ -2509,12 +2647,20 @@ export default function ChatView({ threadId }: ChatViewProps) { settledCwd === current.cwd && (activeThreadWorktreePath ?? null) === current.worktreePath ) { - storeClearTerminalLaunchContext(activeThreadId); + if (activeThreadRef) { + storeClearTerminalLaunchContext(activeThreadRef); + } return null; } return current; }); - }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath, storeClearTerminalLaunchContext]); + }, [ + activeProjectCwd, + activeThreadId, + activeThreadRef, + activeThreadWorktreePath, + storeClearTerminalLaunchContext, + ]); useEffect(() => { if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { @@ -2528,11 +2674,14 @@ export default function ChatView({ threadId }: ChatViewProps) { settledCwd === storeServerTerminalLaunchContext.cwd && (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath ) { - storeClearTerminalLaunchContext(activeThreadId); + if (activeThreadRef) { + storeClearTerminalLaunchContext(activeThreadRef); + } } }, [ activeProjectCwd, activeThreadId, + activeThreadRef, activeThreadWorktreePath, storeClearTerminalLaunchContext, storeServerTerminalLaunchContext, @@ -2542,11 +2691,16 @@ export default function ChatView({ threadId }: ChatViewProps) { if (terminalState.terminalOpen) { return; } - if (activeThreadId) { - storeClearTerminalLaunchContext(activeThreadId); + if (activeThreadRef) { + storeClearTerminalLaunchContext(activeThreadRef); } setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); - }, [activeThreadId, storeClearTerminalLaunchContext, terminalState.terminalOpen]); + }, [ + activeThreadId, + activeThreadRef, + storeClearTerminalLaunchContext, + terminalState.terminalOpen, + ]); useEffect(() => { if (phase !== "running") return; @@ -2559,16 +2713,16 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [phase]); useEffect(() => { - if (!activeThreadId) return; - const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; + if (!activeThreadKey) return; + const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; const current = Boolean(terminalState.terminalOpen); if (!previous && current) { - terminalOpenByThreadRef.current[activeThreadId] = current; + terminalOpenByThreadRef.current[activeThreadKey] = current; setTerminalFocusRequestId((value) => value + 1); return; } else if (previous && !current) { - terminalOpenByThreadRef.current[activeThreadId] = current; + terminalOpenByThreadRef.current[activeThreadKey] = current; const frame = window.requestAnimationFrame(() => { focusComposer(); }); @@ -2577,8 +2731,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }; } - terminalOpenByThreadRef.current[activeThreadId] = current; - }, [activeThreadId, focusComposer, terminalState.terminalOpen]); + terminalOpenByThreadRef.current[activeThreadKey] = current; + }, [activeThreadKey, focusComposer, terminalState.terminalOpen]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { @@ -2837,7 +2991,7 @@ export default function ChatView({ threadId }: ChatViewProps) { planMarkdown: activeProposedPlan.planMarkdown, }); promptRef.current = ""; - clearComposerDraftContent(activeThread.id); + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -2854,7 +3008,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (standaloneSlashCommand) { handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; - clearComposerDraftContent(activeThread.id); + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -2957,7 +3111,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } promptRef.current = ""; - clearComposerDraftContent(threadIdForSend); + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, threadIdForSend)); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -3314,7 +3468,10 @@ export default function ChatView({ threadId }: ChatViewProps) { // Keep the mode toggle and plan-follow-up banner in sync immediately // while the same-thread implementation turn is starting. - setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode); + setComposerDraftInteractionMode( + scopeThreadRef(activeThread.environmentId, threadIdForSend), + nextInteractionMode, + ); await api.orchestration.dispatchCommand({ type: "thread.turn.start", @@ -3454,14 +3611,17 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }) .then(() => { - return waitForStartedServerThread(nextThreadId); + return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)); }) .then(() => { // Signal that the plan sidebar should open on the new thread. planSidebarOpenOnNextThreadRef.current = true; return navigate({ - to: "/$threadId", - params: { threadId: nextThreadId }, + to: "/$environmentId/$threadId", + params: { + environmentId: activeThread.environmentId, + threadId: nextThreadId, + }, }); }) .catch(async (err) => { @@ -3516,7 +3676,10 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: resolvedProvider, model: resolvedModel, }; - setComposerDraftModelSelection(activeThread.id, nextModelSelection); + setComposerDraftModelSelection( + scopeThreadRef(activeThread.environmentId, activeThread.id), + nextModelSelection, + ); setStickyComposerModelSelection(nextModelSelection); scheduleComposerFocus(); }, @@ -3548,7 +3711,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ provider: selectedProvider, - threadId, + threadRef: routeThreadRef, model: selectedModel, models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], @@ -3557,7 +3720,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); const providerTraitsPicker = renderProviderTraitsPicker({ provider: selectedProvider, - threadId, + threadRef: routeThreadRef, model: selectedModel, models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], @@ -3567,11 +3730,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { - setDraftThreadContext(threadId, { envMode: mode }); + setDraftThreadContext(routeThreadRef, { envMode: mode }); } scheduleComposerFocus(); }, - [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], + [isLocalDraftThread, routeThreadRef, scheduleComposerFocus, setDraftThreadContext], ); const applyPromptReplacement = useCallback( @@ -3768,7 +3931,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setPrompt(nextPrompt); if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { setComposerDraftTerminalContexts( - threadId, + routeThreadRef, syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), ); } @@ -3784,7 +3947,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onChangeActivePendingUserInputCustomAnswer, setPrompt, setComposerDraftTerminalContexts, - threadId, + routeThreadRef, ], ); @@ -3838,8 +4001,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { void navigate({ - to: "/$threadId", - params: { threadId }, + to: "/$environmentId/$threadId", + params: { + environmentId, + threadId, + }, search: (previous) => { const rest = stripDiffSearchParams(previous); return filePath @@ -3848,7 +4014,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, }); }, - [navigate, threadId], + [environmentId, navigate, threadId], ); const onRevertUserMessage = (messageId: MessageId) => { const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); @@ -3894,6 +4060,7 @@ export default function ChatView({ threadId }: ChatViewProps) { )} > {/* end horizontal flex container */} - {mountedTerminalThreadIds.map((mountedThreadId) => ( - - ))} + {mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { + const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); + if (!mountedThreadRef) { + return []; + } + return [ + , + ]; + })} {expandedImage && expandedImageItem && (
(params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), + select: (params) => resolveThreadRouteRef(params), }); const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); const diffOpen = diffSearch.diff === "1"; - const activeThreadId = routeThreadId; + const activeThreadId = routeThreadRef?.threadId ?? null; const activeThread = useStore( - useMemo(() => createThreadSelector(activeThreadId), [activeThreadId]), + useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), ); const activeProjectId = activeThread?.projectId ?? null; const activeProject = useStore((store) => - activeProjectId ? store.projectById[activeProjectId] : undefined, + activeThread && activeProjectId + ? selectProjectByRef(store, { + environmentId: activeThread.environmentId, + projectId: activeProjectId, + }) + : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; const gitStatusQuery = useGitStatus(activeCwd ?? null); @@ -335,8 +342,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const selectTurn = (turnId: TurnId) => { if (!activeThread) return; void navigate({ - to: "/$threadId", - params: { threadId: activeThread.id }, + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), search: (previous) => { const rest = stripDiffSearchParams(previous); return { ...rest, diff: "1", diffTurnId: turnId }; @@ -346,8 +353,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const selectWholeConversation = () => { if (!activeThread) return; void navigate({ - to: "/$threadId", - params: { threadId: activeThread.id }, + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), search: (previous) => { const rest = stripDiffSearchParams(previous); return { ...rest, diff: "1" }; diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index ffdb01e9d5..7a06263210 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -1,3 +1,4 @@ +import { scopeThreadRef } from "@t3tools/client-runtime"; import { ThreadId } from "@t3tools/contracts"; import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -5,6 +6,7 @@ import { render } from "vitest-browser-react"; const THREAD_A = ThreadId.makeUnsafe("thread-a"); const THREAD_B = ThreadId.makeUnsafe("thread-b"); +const ENVIRONMENT_ID = "environment-local" as never; const GIT_CWD = "/repo/project"; const BRANCH_NAME = "feature/toast-scope"; @@ -168,7 +170,10 @@ function Harness() { - + ); } @@ -248,9 +253,15 @@ describe("GitActionsControl thread-scoped progress toast", () => { const host = document.createElement("div"); document.body.append(host); - const screen = await render(, { - container: host, - }); + const screen = await render( + , + { + container: host, + }, + ); try { window.dispatchEvent(new Event("focus")); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2c9222ee36..faca6cad36 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,9 +1,9 @@ +import { type ScopedThreadRef } from "@t3tools/contracts"; import type { GitActionProgressEvent, GitRunStackedActionResult, GitStackedAction, GitStatusResult, - ThreadId, } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; @@ -51,10 +51,11 @@ import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; import { useStore } from "~/store"; +import { createThreadSelectorByRef } from "~/storeSelectors"; interface GitActionsControlProps { gitCwd: string | null; - activeThreadId: ThreadId | null; + activeThreadRef: ScopedThreadRef | null; } interface PendingDefaultBranchAction { @@ -206,14 +207,17 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { return ; } -export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { +export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) { + const activeThreadId = activeThreadRef?.threadId ?? null; const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], ); - const activeServerThread = useStore((store) => - activeThreadId ? store.threadShellById[activeThreadId] : undefined, + const activeServerThreadSelector = useMemo( + () => createThreadSelectorByRef(activeThreadRef), + [activeThreadRef], ); + const activeServerThread = useStore(activeServerThreadSelector); const setThreadBranch = useStore((store) => store.setThreadBranch); const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index d645546042..04c8a891b9 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -35,6 +35,7 @@ vi.mock("../lib/gitStatusState", () => ({ const THREAD_ID = "thread-kb-toast-test" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; +const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); const NOW_ISO = "2026-03-04T12:00:00.000Z"; interface TestFixture { @@ -51,7 +52,7 @@ const wsLink = ws.link(/ws(s)?:\/\/.*/); function createBaseServerConfig(): ServerConfig { return { environment: { - environmentId: EnvironmentId.makeUnsafe("environment-local"), + environmentId: LOCAL_ENVIRONMENT_ID, label: "Local environment", platform: { os: "darwin" as const, arch: "arm64" as const }, serverVersion: "0.0.0-test", @@ -164,7 +165,7 @@ function buildFixture(): TestFixture { serverConfig: createBaseServerConfig(), welcome: { environment: { - environmentId: EnvironmentId.makeUnsafe("environment-local"), + environmentId: LOCAL_ENVIRONMENT_ID, label: "Local environment", platform: { os: "darwin" as const, arch: "arm64" as const }, serverVersion: "0.0.0-test", @@ -301,7 +302,9 @@ async function mountApp(): Promise<{ cleanup: () => Promise }> { host.style.overflow = "hidden"; document.body.append(host); - const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] })); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`] }), + ); const screen = await render( @@ -366,28 +369,13 @@ describe("Keybindings update toast", () => { localStorage.clear(); document.body.innerHTML = ""; useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + projectDraftThreadKeyByProjectKey: {}, }); useStore.setState({ - projectIds: [], - projectById: {}, - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - sidebarThreadSummaryById: {}, - bootstrapComplete: false, + activeEnvironmentId: null, + environmentStateById: {}, }); }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 923d9d88e8..15d390460b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -45,10 +45,18 @@ import { CSS } from "@dnd-kit/utilities"; import { DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, + type EnvironmentId, ProjectId, + type ScopedThreadRef, ThreadId, type GitStatusResult, } from "@t3tools/contracts"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, @@ -58,7 +66,11 @@ 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 { useStore } from "../store"; +import { + selectProjectsAcrossEnvironments, + selectSidebarThreadsAcrossEnvironments, + useStore, +} from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { @@ -75,6 +87,7 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions"; +import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; @@ -110,7 +123,6 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { getVisibleSidebarThreadIds, - getVisibleThreadsForProject, resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, @@ -128,7 +140,7 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import type { Project } from "../types"; +import type { Project, SidebarThreadSummary } from "../types"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -145,6 +157,7 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { } as const; type SidebarProjectSnapshot = Project & { + projectKey: string; expanded: boolean; }; interface TerminalStatusIndicator { @@ -255,56 +268,58 @@ function resolveThreadPr( } interface SidebarThreadRowProps { - threadId: ThreadId; + thread: SidebarThreadSummary; projectCwd: string | null; - orderedProjectThreadIds: readonly ThreadId[]; - routeThreadId: ThreadId | null; - selectedThreadIds: ReadonlySet; + orderedProjectThreadKeys: readonly string[]; + routeThreadKey: string | null; + selectedThreadKeys: ReadonlySet; showThreadJumpHints: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; - renamingThreadId: ThreadId | null; + renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; renamingInputRef: MutableRefObject; renamingCommittedRef: MutableRefObject; - confirmingArchiveThreadId: ThreadId | null; - setConfirmingArchiveThreadId: Dispatch>; - confirmArchiveButtonRefs: MutableRefObject>; + confirmingArchiveThreadKey: string | null; + setConfirmingArchiveThreadKey: Dispatch>; + confirmArchiveButtonRefs: MutableRefObject>; handleThreadClick: ( event: MouseEvent, - threadId: ThreadId, - orderedProjectThreadIds: readonly ThreadId[], + threadRef: ScopedThreadRef, + orderedProjectThreadKeys: readonly string[], ) => void; - navigateToThread: (threadId: ThreadId) => void; + navigateToThread: (threadRef: ScopedThreadRef) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( - threadId: ThreadId, + threadRef: ScopedThreadRef, position: { x: number; y: number }, ) => Promise; clearSelection: () => void; - commitRename: (threadId: ThreadId, newTitle: string, originalTitle: string) => Promise; + commitRename: ( + threadRef: ScopedThreadRef, + newTitle: string, + originalTitle: string, + ) => Promise; cancelRename: () => void; - attemptArchiveThread: (threadId: ThreadId) => Promise; + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: MouseEvent, prUrl: string) => void; } function SidebarThreadRow(props: SidebarThreadRowProps) { - const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]); - const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); + const thread = props.thread; + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const runningTerminalIds = useTerminalStateStore( (state) => - selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, ); - const gitCwd = thread?.worktreePath ?? props.projectCwd; - const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null); - - if (!thread) { - return null; - } + const gitCwd = thread.worktreePath ?? props.projectCwd; + const gitStatus = useGitStatus(thread.branch != null ? gitCwd : null); - const isActive = props.routeThreadId === thread.id; - const isSelected = props.selectedThreadIds.has(thread.id); + const isActive = props.routeThreadKey === threadKey; + const isSelected = props.selectedThreadKeys.has(threadKey); const isHighlighted = isActive || isSelected; const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; @@ -317,7 +332,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { const pr = resolveThreadPr(thread.branch, gitStatus.data); const prStatus = prStatusIndicator(pr); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning; + const isConfirmingArchive = props.confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive ? "pointer-events-none opacity-0" : !isThreadRunning @@ -329,7 +344,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { className="w-full" data-thread-item onMouseLeave={() => { - props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); + props.setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); }} onBlurCapture={(event) => { const currentTarget = event.currentTarget; @@ -337,7 +352,9 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { if (currentTarget.contains(document.activeElement)) { return; } - props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); + props.setConfirmingArchiveThreadKey((current) => + current === threadKey ? null : current, + ); }); }} > @@ -351,25 +368,25 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { isSelected, })} relative isolate`} onClick={(event) => { - props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds); + props.handleThreadClick(event, threadRef, props.orderedProjectThreadKeys); }} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); - props.navigateToThread(thread.id); + props.navigateToThread(threadRef); }} onContextMenu={(event) => { event.preventDefault(); - if (props.selectedThreadIds.size > 0 && props.selectedThreadIds.has(thread.id)) { + if (props.selectedThreadKeys.size > 0 && props.selectedThreadKeys.has(threadKey)) { void props.handleMultiSelectContextMenu({ x: event.clientX, y: event.clientY, }); } else { - if (props.selectedThreadIds.size > 0) { + if (props.selectedThreadKeys.size > 0) { props.clearSelection(); } - void props.handleThreadContextMenu(thread.id, { + void props.handleThreadContextMenu(threadRef, { x: event.clientX, y: event.clientY, }); @@ -397,7 +414,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { )} {threadStatus && } - {props.renamingThreadId === thread.id ? ( + {props.renamingThreadKey === threadKey ? ( { if (element && props.renamingInputRef.current !== element) { @@ -414,7 +431,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { if (event.key === "Enter") { event.preventDefault(); props.renamingCommittedRef.current = true; - void props.commitRename(thread.id, props.renamingTitle, thread.title); + void props.commitRename(threadRef, props.renamingTitle, thread.title); } else if (event.key === "Escape") { event.preventDefault(); props.renamingCommittedRef.current = true; @@ -423,7 +440,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { }} onBlur={() => { if (!props.renamingCommittedRef.current) { - void props.commitRename(thread.id, props.renamingTitle, thread.title); + void props.commitRename(threadRef, props.renamingTitle, thread.title); } }} onClick={(event) => event.stopPropagation()} @@ -448,9 +465,9 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { @@ -423,36 +558,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} {threadStatus && } - {props.renamingThreadKey === threadKey ? ( + {renamingThreadKey === threadKey ? ( { - if (element && props.renamingInputRef.current !== element) { - props.renamingInputRef.current = element; - element.focus(); - element.select(); - } - }} + ref={handleRenameInputRef} className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" - value={props.renamingTitle} - onChange={(event) => props.setRenamingTitle(event.target.value)} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - props.renamingCommittedRef.current = true; - void props.commitRename(threadRef, props.renamingTitle, thread.title); - } else if (event.key === "Escape") { - event.preventDefault(); - props.renamingCommittedRef.current = true; - props.cancelRename(); - } - }} - onBlur={() => { - if (!props.renamingCommittedRef.current) { - void props.commitRename(threadRef, props.renamingTitle, thread.title); - } - }} - onClick={(event) => event.stopPropagation()} + value={renamingTitle} + onChange={handleRenameInputChange} + onKeyDown={handleRenameInputKeyDown} + onBlur={handleRenameInputBlur} + onClick={handleRenameInputClick} /> ) : ( {thread.title} @@ -472,34 +586,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
{isConfirmingArchive ? ( ) : !isThreadRunning ? ( - props.appSettingsConfirmThreadArchive ? ( + appSettingsConfirmThreadArchive ? (
@@ -533,14 +623,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP data-testid={`thread-archive-${thread.id}`} aria-label={`Archive ${thread.title}`} className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring" - onPointerDown={(event) => { - event.stopPropagation(); - }} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - void props.attemptArchiveThread(threadRef); - }} + onPointerDown={stopPropagationOnPointerDown} + onClick={handleArchiveImmediateClick} > @@ -552,12 +636,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) ) : null} - {props.showThreadJumpHints && props.jumpLabel ? ( + {jumpLabel ? ( - {props.jumpLabel} + {jumpLabel} ) : ( string | null) | null; + threadJumpLabelByKey: ReadonlyMap; appSettingsConfirmThreadArchive: boolean; renamingThreadKey: string | null; renamingTitle: string; @@ -641,8 +724,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( isThreadListExpanded, projectCwd, routeThreadKey, - showThreadJumpHints, - getThreadJumpLabel, + threadJumpLabelByKey, appSettingsConfirmThreadArchive, renamingThreadKey, renamingTitle, @@ -691,10 +773,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( projectCwd={projectCwd} orderedProjectThreadKeys={orderedProjectThreadKeys} routeThreadKey={routeThreadKey} - showThreadJumpHints={showThreadJumpHints} - jumpLabel={ - showThreadJumpHints && getThreadJumpLabel ? getThreadJumpLabel(threadKey) : null - } + jumpLabel={threadJumpLabelByKey.get(threadKey) ?? null} appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive} renamingThreadKey={renamingThreadKey} renamingTitle={renamingTitle} @@ -757,8 +836,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( interface SidebarProjectItemProps { project: SidebarProjectSnapshot; isThreadListExpanded: boolean; - showThreadJumpHints: boolean; - getThreadJumpLabel: ((threadKey: string) => string | null) | null; + threadJumpLabelByKey: ReadonlyMap; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; @@ -773,8 +851,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const { project, isThreadListExpanded, - showThreadJumpHints, - getThreadJumpLabel, + threadJumpLabelByKey, attachThreadListAutoAnimateRef, expandThreadListForProject, collapseThreadListForProject, @@ -790,6 +867,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); const routeThreadKey = routeTarget?.kind === "server" ? scopedThreadKey(routeTarget.threadRef) : null; + const routeServerThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; const itemSettings = useSettings(); const threadSortOrder = itemSettings.sidebarThreadSortOrder; const appSettingsConfirmThreadDelete = itemSettings.confirmThreadDelete; @@ -827,14 +905,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec : state.getDraftSession(routeTarget.draftId) : null, ); - const routeTerminalOpen = - routeTarget?.kind === "server" - ? useTerminalStateStore( - (state) => - selectThreadTerminalState(state.terminalStateByThreadKey, routeTarget.threadRef) - .terminalOpen, - ) - : false; + const routeTerminalOpen = useTerminalStateStore((state) => + routeServerThreadRef + ? selectThreadTerminalState(state.terminalStateByThreadKey, routeServerThreadRef).terminalOpen + : false, + ); const sidebarShortcutLabelOptions = useMemo( () => ({ platform: navigator.platform, @@ -1561,8 +1636,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec isThreadListExpanded={isThreadListExpanded} projectCwd={project.cwd} routeThreadKey={routeThreadKey} - showThreadJumpHints={showThreadJumpHints} - getThreadJumpLabel={getThreadJumpLabel} + threadJumpLabelByKey={threadJumpLabelByKey} appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive} renamingThreadKey={renamingThreadKey} renamingTitle={renamingTitle} @@ -1714,6 +1788,371 @@ function SortableProjectItem({ ); } +const SidebarChromeHeader = memo(function SidebarChromeHeader({ + isElectron, +}: { + isElectron: boolean; +}) { + const wordmark = ( +
+ + + + + + Code + + + {APP_STAGE_LABEL} + + + } + /> + + Version {APP_VERSION} + + +
+ ); + + return isElectron ? ( + + {wordmark} + + ) : ( + {wordmark} + ); +}); + +const SidebarChromeFooter = memo(function SidebarChromeFooter({ + navigate, +}: { + navigate: ReturnType; +}) { + const handleSettingsClick = useCallback(() => { + void navigate({ to: "/settings" }); + }, [navigate]); + + return ( + + + + + + + Settings + + + + + ); +}); + +interface SidebarProjectsContentProps { + showArm64IntelBuildWarning: boolean; + arm64IntelBuildWarningDescription: string | null; + desktopUpdateButtonAction: "download" | "install" | "none"; + desktopUpdateButtonDisabled: boolean; + handleDesktopUpdateButtonClick: () => void; + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + updateSettings: ReturnType["updateSettings"]; + shouldShowProjectPathEntry: boolean; + handleStartAddProject: () => void; + isElectron: boolean; + isPickingFolder: boolean; + isAddingProject: boolean; + handlePickFolder: () => Promise; + addProjectInputRef: MutableRefObject; + addProjectError: string | null; + newCwd: string; + setNewCwd: Dispatch>; + setAddProjectError: Dispatch>; + handleAddProject: () => void; + setAddingProject: Dispatch>; + canAddProject: boolean; + isManualProjectSorting: boolean; + projectDnDSensors: ReturnType; + projectCollisionDetection: CollisionDetection; + handleProjectDragStart: (event: DragStartEvent) => void; + handleProjectDragEnd: (event: DragEndEvent) => void; + handleProjectDragCancel: (event: DragCancelEvent) => void; + sortedProjects: readonly SidebarProjectSnapshot[]; + expandedThreadListsByProject: ReadonlySet; + threadJumpLabelByKey: ReadonlyMap; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + expandThreadListForProject: (projectKey: string) => void; + collapseThreadListForProject: (projectKey: string) => void; + dragInProgressRef: MutableRefObject; + suppressProjectClickAfterDragRef: MutableRefObject; + suppressProjectClickForContextMenuRef: MutableRefObject; + attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; + projectsLength: number; +} + +const SidebarProjectsContent = memo(function SidebarProjectsContent( + props: SidebarProjectsContentProps, +) { + const { + showArm64IntelBuildWarning, + arm64IntelBuildWarningDescription, + desktopUpdateButtonAction, + desktopUpdateButtonDisabled, + handleDesktopUpdateButtonClick, + projectSortOrder, + threadSortOrder, + updateSettings, + shouldShowProjectPathEntry, + handleStartAddProject, + isElectron, + isPickingFolder, + isAddingProject, + handlePickFolder, + addProjectInputRef, + addProjectError, + newCwd, + setNewCwd, + setAddProjectError, + handleAddProject, + setAddingProject, + canAddProject, + isManualProjectSorting, + projectDnDSensors, + projectCollisionDetection, + handleProjectDragStart, + handleProjectDragEnd, + handleProjectDragCancel, + sortedProjects, + expandedThreadListsByProject, + threadJumpLabelByKey, + attachThreadListAutoAnimateRef, + expandThreadListForProject, + collapseThreadListForProject, + dragInProgressRef, + suppressProjectClickAfterDragRef, + suppressProjectClickForContextMenuRef, + attachProjectListAutoAnimateRef, + projectsLength, + } = props; + + const handleProjectSortOrderChange = useCallback( + (sortOrder: SidebarProjectSortOrder) => { + updateSettings({ sidebarProjectSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleThreadSortOrderChange = useCallback( + (sortOrder: SidebarThreadSortOrder) => { + updateSettings({ sidebarThreadSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleAddProjectInputChange = useCallback( + (event: ChangeEvent) => { + setNewCwd(event.target.value); + setAddProjectError(null); + }, + [setAddProjectError, setNewCwd], + ); + const handleAddProjectInputKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Enter") handleAddProject(); + if (event.key === "Escape") { + setAddingProject(false); + setAddProjectError(null); + } + }, + [handleAddProject, setAddProjectError, setAddingProject], + ); + const handleBrowseForFolderClick = useCallback(() => { + void handlePickFolder(); + }, [handlePickFolder]); + + return ( + + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( + + + + Intel build on Apple Silicon + {arm64IntelBuildWarningDescription} + {desktopUpdateButtonAction !== "none" ? ( + + + + ) : null} + + + ) : null} + +
+ + Projects + +
+ + + + } + > + + + + {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} + + +
+
+ {shouldShowProjectPathEntry && ( +
+ {isElectron && ( + + )} +
+ + +
+ {addProjectError && ( +

+ {addProjectError} +

+ )} +
+ )} + + {isManualProjectSorting ? ( + + + project.projectKey)} + strategy={verticalListSortingStrategy} + > + {sortedProjects.map((project) => ( + + {(dragHandleProps) => ( + + )} + + ))} + + + + ) : ( + + {sortedProjects.map((project) => ( + + + + ))} + + )} + + {projectsLength === 0 && !shouldShowProjectPathEntry && ( +
+ No projects yet +
+ )} +
+
+ ); +}); + export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); @@ -2108,10 +2547,12 @@ export default function Sidebar() { } return mapping; }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandByKey]); - const getThreadJumpLabel = useMemo<((threadKey: string) => string | null) | null>( - () => (showThreadJumpHints ? (threadKey) => threadJumpLabelByKey.get(threadKey) ?? null : null), - [showThreadJumpHints, threadJumpLabelByKey], - ); + const visibleThreadJumpLabelByKey = useMemo(() => { + if (!showThreadJumpHints) { + return new Map(); + } + return threadJumpLabelByKey; + }, [showThreadJumpHints, threadJumpLabelByKey]); const orderedSidebarThreadKeys = visibleSidebarThreadKeys; useEffect(() => { @@ -2347,262 +2788,58 @@ export default function Sidebar() { }); }, []); - const wordmark = ( -
- - - - - - Code - - - {APP_STAGE_LABEL} - - - } - /> - - Version {APP_VERSION} - - -
- ); - return ( <> - {isElectron ? ( - - {wordmark} - - ) : ( - - {wordmark} - - )} + {isOnSettings ? ( ) : ( <> - - {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( - - - - Intel build on Apple Silicon - {arm64IntelBuildWarningDescription} - {desktopUpdateButtonAction !== "none" ? ( - - - - ) : null} - - - ) : null} - -
- - Projects - -
- { - updateSettings({ sidebarProjectSortOrder: sortOrder }); - }} - onThreadSortOrderChange={(sortOrder) => { - updateSettings({ sidebarThreadSortOrder: sortOrder }); - }} - /> - - - } - > - - - - {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} - - -
-
- {shouldShowProjectPathEntry && ( -
- {isElectron && ( - - )} -
- { - setNewCwd(event.target.value); - setAddProjectError(null); - }} - onKeyDown={(event) => { - if (event.key === "Enter") handleAddProject(); - if (event.key === "Escape") { - setAddingProject(false); - setAddProjectError(null); - } - }} - autoFocus - /> - -
- {addProjectError && ( -

- {addProjectError} -

- )} -
- )} - - {isManualProjectSorting ? ( - - - project.projectKey)} - strategy={verticalListSortingStrategy} - > - {sortedProjects.map((project) => ( - - {(dragHandleProps) => ( - - )} - - ))} - - - - ) : ( - - {sortedProjects.map((project) => ( - - - - ))} - - )} - - {projects.length === 0 && !shouldShowProjectPathEntry && ( -
- No projects yet -
- )} -
-
+ - - - - - void navigate({ to: "/settings" })} - > - - Settings - - - - + )} From 8fc0a84506f8e6678e7545a6b1e3286a155fbc4f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 7 Apr 2026 10:51:33 -0700 Subject: [PATCH 32/48] render optimization 4 --- apps/web/src/components/Sidebar.tsx | 332 ++++++++++++++++++---------- apps/web/src/hooks/useSettings.ts | 4 +- 2 files changed, 218 insertions(+), 118 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ab1f6db564..16d0ea732e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -51,6 +51,7 @@ import { type EnvironmentId, ProjectId, type ScopedThreadRef, + type ThreadEnvMode, ThreadId, type GitStatusResult, } from "@t3tools/contracts"; @@ -60,7 +61,7 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; -import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router"; +import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, type SidebarThreadSortOrder, @@ -73,6 +74,7 @@ import { selectProjectsAcrossEnvironments, selectSidebarThreadsForProjectRef, selectSidebarThreadsAcrossEnvironments, + selectThreadByRef, useStore, } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; @@ -149,7 +151,6 @@ import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import type { Project, SidebarThreadSummary } from "../types"; -import { createThreadSelectorByRef } from "../storeSelectors"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -164,6 +165,7 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; +const EMPTY_THREAD_JUMP_LABELS = new Map(); type SidebarProjectSnapshot = Project & { projectKey: string; @@ -279,7 +281,7 @@ interface SidebarThreadRowProps { thread: SidebarThreadSummary; projectCwd: string | null; orderedProjectThreadKeys: readonly string[]; - routeThreadKey: string | null; + isActive: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; renamingThreadKey: string | null; @@ -312,10 +314,31 @@ interface SidebarThreadRowProps { openPrLink: (event: MouseEvent, prUrl: string) => void; } +function areSidebarThreadRowPropsEqual( + prev: Readonly, + next: Readonly, +): boolean { + return ( + prev.thread === next.thread && + prev.projectCwd === next.projectCwd && + prev.orderedProjectThreadKeys === next.orderedProjectThreadKeys && + prev.isActive === next.isActive && + prev.jumpLabel === next.jumpLabel && + prev.appSettingsConfirmThreadArchive === next.appSettingsConfirmThreadArchive && + prev.renamingThreadKey === next.renamingThreadKey && + prev.renamingTitle === next.renamingTitle && + prev.renamingInputRef === next.renamingInputRef && + prev.renamingCommittedRef === next.renamingCommittedRef && + prev.confirmingArchiveThreadKey === next.confirmingArchiveThreadKey && + prev.setConfirmingArchiveThreadKey === next.setConfirmingArchiveThreadKey && + prev.confirmArchiveButtonRefs === next.confirmArchiveButtonRefs + ); +} + const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, - routeThreadKey, + isActive, jumpLabel, appSettingsConfirmThreadArchive, renamingThreadKey, @@ -351,8 +374,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP environmentId: thread.environmentId, cwd: thread.branch != null ? gitCwd : null, }); - - const isActive = routeThreadKey === threadKey; const isHighlighted = isActive || isSelected; const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; @@ -518,6 +539,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }, [attemptArchiveThread, threadRef], ); + const rowButtonRender = useMemo(() =>
, []); return ( } + render={rowButtonRender} size="sm" isActive={isActive} data-testid={`thread-row-${thread.id}`} @@ -660,7 +682,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ); -}); +}, areSidebarThreadRowPropsEqual); interface SidebarProjectThreadListProps { projectKey: string; @@ -673,7 +695,7 @@ interface SidebarProjectThreadListProps { shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; projectCwd: string; - routeThreadKey: string | null; + activeRouteThreadKey: string | null; threadJumpLabelByKey: ReadonlyMap; appSettingsConfirmThreadArchive: boolean; renamingThreadKey: string | null; @@ -709,6 +731,34 @@ interface SidebarProjectThreadListProps { collapseThreadListForProject: (projectKey: string) => void; } +function areSidebarProjectThreadListPropsEqual( + prev: Readonly, + next: Readonly, +): boolean { + return ( + prev.projectKey === next.projectKey && + prev.projectExpanded === next.projectExpanded && + prev.hasOverflowingThreads === next.hasOverflowingThreads && + prev.hiddenThreadStatus === next.hiddenThreadStatus && + prev.orderedProjectThreadKeys === next.orderedProjectThreadKeys && + prev.renderedThreads === next.renderedThreads && + prev.showEmptyThreadState === next.showEmptyThreadState && + prev.shouldShowThreadPanel === next.shouldShowThreadPanel && + prev.isThreadListExpanded === next.isThreadListExpanded && + prev.projectCwd === next.projectCwd && + prev.activeRouteThreadKey === next.activeRouteThreadKey && + prev.threadJumpLabelByKey === next.threadJumpLabelByKey && + prev.appSettingsConfirmThreadArchive === next.appSettingsConfirmThreadArchive && + prev.renamingThreadKey === next.renamingThreadKey && + prev.renamingTitle === next.renamingTitle && + prev.renamingInputRef === next.renamingInputRef && + prev.renamingCommittedRef === next.renamingCommittedRef && + prev.confirmingArchiveThreadKey === next.confirmingArchiveThreadKey && + prev.setConfirmingArchiveThreadKey === next.setConfirmingArchiveThreadKey && + prev.confirmArchiveButtonRefs === next.confirmArchiveButtonRefs + ); +} + const SidebarProjectThreadList = memo(function SidebarProjectThreadList( props: SidebarProjectThreadListProps, ) { @@ -723,7 +773,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( shouldShowThreadPanel, isThreadListExpanded, projectCwd, - routeThreadKey, + activeRouteThreadKey, threadJumpLabelByKey, appSettingsConfirmThreadArchive, renamingThreadKey, @@ -747,6 +797,8 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( expandThreadListForProject, collapseThreadListForProject, } = props; + const showMoreButtonRender = useMemo(() =>