diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 7adf6f0e8a5e..9a57cbc96cbf 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -291,8 +291,6 @@ export function HomeScreen(props: HomeScreenProps) { ) ? [props.selectedEnvironmentId] : []; - const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); - const threadSearchMatchByKey = useThreadListSearchMatches(props.threads, threadSearch); const handleSwipeableWillOpen = (methods: SwipeableMethods) => { if (openSwipeableRef.current !== methods) { openSwipeableRef.current?.close(); @@ -380,6 +378,14 @@ export function HomeScreen(props: HomeScreenProps) { const v2ProjectScopeKey = props.selectedProjectKey; const v2ScopedProjectGroup = findHomeProjectScope(projectScopes, v2ProjectScopeKey); + const threadSearch = useThreadSearch( + searchEnvironmentIds, + props.searchQuery, + v2ScopedProjectGroup === null + ? {} + : { projectIds: v2ScopedProjectGroup.projectRefs.map((ref) => ref.projectId) }, + ); + const threadSearchMatchByKey = useThreadListSearchMatches(props.threads, threadSearch); const v2ProjectTitleByProjectKey = new Map( projectScopes.flatMap((scope) => scope.projectRefs.map( @@ -447,6 +453,7 @@ export function HomeScreen(props: HomeScreenProps) { layout: threadListV2Layout, items: threadListV2Items, contentOnlyThreadKeys, + olderSettledShelf, } = useThreadListV2({ threads: props.threads, pendingTasks: props.pendingTasks, @@ -658,7 +665,9 @@ export function HomeScreen(props: HomeScreenProps) { }; const hasAnyThreads = - props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; + props.threads.some((thread) => thread.archivedAt === null) || + props.pendingTasks.length > 0 || + threadListV2Layout.settledCount > 0; const selectedEnvironmentLabel = props.selectedEnvironmentId === null ? null @@ -806,7 +815,15 @@ export function HomeScreen(props: HomeScreenProps) { settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? ( { + olderSettledShelf.retry(); + showMoreSettled(); + }} /> ) : null } diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 425fab9c56c5..ac88e0b3c0c5 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -241,7 +241,21 @@ export function CommandPalette(props: { .map((environment) => environment.environmentId), [environments], ); - const search = useThreadSearch(searchEnvironmentIds, query.startsWith(">") ? "" : query); + // The palette matches environment names when there are several; older rows must match the same. + const environmentLabels = + environments.length > 1 + ? Object.fromEntries( + searchEnvironmentIds.flatMap((environmentId) => { + const label = savedConnectionsById[environmentId]?.environmentLabel; + return label === undefined ? [] : [[environmentId, label]]; + }), + ) + : undefined; + const search = useThreadSearch( + searchEnvironmentIds, + query.startsWith(">") ? "" : query, + environmentLabels === undefined ? {} : { environmentLabels }, + ); const contentMatchByKey = useMemo( () => new Map( diff --git a/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts b/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts index 4a3557c19dd6..4b31055ae2c9 100644 --- a/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts +++ b/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts @@ -2,7 +2,7 @@ import * as Dialog from "../../components/ui/Dialog"; import { StackActions, useNavigation } from "@react-navigation/native"; import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; import { - findPromptLinkProject, + findPromptLinkProjects, findPromptLinkPullRequestThread, resolvePromptLinkModelSelection, } from "@t3tools/client-runtime/prompt-links"; @@ -11,7 +11,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { parseNewThreadLink, type LinkedPrompt, @@ -63,6 +63,9 @@ export function usePromptLinkResolver(params: Readonly> const loadPullRequest = useAtomQueryRunner(composerPullRequests.detail, { reportFailure: false, }); + const loadLinkedThreads = useAtomQueryRunner(composerPullRequests.linkedThreadsUncached, { + reportFailure: false, + }); const search = promptLinkSearchFromParams(params); // Wait for cold-launch shells or existing repositories may be offered for cloning. const waitingForShells = @@ -89,6 +92,61 @@ export function usePromptLinkResolver(params: Readonly> return resolved?.exact !== true; }; + const openThread = ( + thread: { readonly environmentId: EnvironmentId; readonly id: ThreadId }, + prompt: LinkedDraftRequest["prompt"], + ) => { + (navigation.getParent() ?? navigation).dispatch( + StackActions.replace("Thread", { + environmentId: String(thread.environmentId), + threadId: String(thread.id), + }), + ); + if (prompt) { + void applyLinkedPromptToThreadDraft( + scopedThreadKey(thread.environmentId, thread.id), + prompt, + promptLinkNotice({ hiddenCharacters: prompt.hiddenCharacters }), + ); + } + }; + + // A pull request's thread can be an older settled thread no shell holds, so ask every + // connected environment with the repository. A lookup on another one would wait forever. + const findServerPullRequestThread = async ( + projects: ReadonlyArray, + pullRequest: PromptLinkPullRequest, + ) => { + const connected = new Set( + connectedEnvironments + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId), + ); + const lookups = projects + .filter((project) => connected.has(project.environmentId)) + .map(async (project) => { + const linked = await loadLinkedThreads({ + environmentId: project.environmentId, + input: { + projectId: project.id, + repository: promptLinkRepositoryName(pullRequest), + number: pullRequest.number, + }, + }); + const thread = + linked._tag === "Success" + ? linked.value.threads.find((candidate) => candidate.archivedAt === null) + : undefined; + return thread && { environmentId: project.environmentId, id: thread.id }; + }); + // The most recent project wins without waiting for the lookups after it. + for (const lookup of lookups) { + const thread = await lookup; + if (thread) return thread; + } + return undefined; + }; + const openProjectDraft = async (project: EnvironmentProject, request: LinkedDraftRequest) => { try { await waitForComposerDraftsLoaded(); @@ -234,19 +292,7 @@ export function usePromptLinkResolver(params: Readonly> if (link.kind === "pull-request") { const thread = findPromptLinkPullRequestThread(threads, link.pullRequest); if (thread) { - (navigation.getParent() ?? navigation).dispatch( - StackActions.replace("Thread", { - environmentId: String(thread.environmentId), - threadId: String(thread.id), - }), - ); - if (link.prompt) { - void applyLinkedPromptToThreadDraft( - scopedThreadKey(thread.environmentId, thread.id), - link.prompt, - promptLinkNotice({ hiddenCharacters: link.prompt.hiddenCharacters }), - ); - } + openThread(thread, link.prompt); return; } } @@ -263,14 +309,20 @@ export function usePromptLinkResolver(params: Readonly> } const activityOf = (project: EnvironmentProject) => lastActivity.get(`${project.environmentId}:${project.id}`) ?? ""; - const project = findPromptLinkProject( + const projects = findPromptLinkProjects( flow.projectScopes .flatMap((scope) => scope.projects) .sort((left, right) => activityOf(right).localeCompare(activityOf(left))), repository, ); + const project = projects[0]; if (project) { - void openProjectDraft(project, request); + const { pullRequest } = request; + void (async () => { + const thread = pullRequest && (await findServerPullRequestThread(projects, pullRequest)); + if (thread) openThread(thread, request.prompt); + else await openProjectDraft(project, request); + })(); return; } Dialog.alert(`You don't have ${promptLinkRepositoryName(repository)}`, undefined, [ diff --git a/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx index 6d8bb10d9072..997e1683f07a 100644 --- a/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx @@ -42,6 +42,7 @@ import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-en import { leaveSettingsPage } from "./settings-navigation"; import { workspaceHasColumns } from "../workspace/workspace-selection-store"; import { DEFAULT_SETTINGS_PAGE } from "../workspace/workspace-tab-navigation"; +import { readUnloadedThreadCount } from "../../state/olderSettledShelf"; export function SettingsProjectOverviewRouteScreen() { const insets = useSafeAreaInsets(); @@ -175,6 +176,7 @@ function ProjectOverviewContent(props: { hasOtherMembers: props.hasOtherMembers, displayName, threads, + unloadedThreadCount: readUnloadedThreadCount(members, threads), }); Dialog.confirm({ title: plan.targetKind === "checkout" ? "Remove checkout" : "Remove project", diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx index 447254e2bbf2..c94ea9b478a1 100644 --- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -16,6 +16,8 @@ import { SymbolView } from "../../components/ui/AppSymbol"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { environmentServerConfigsAtom } from "../../state/server"; import { environmentThreadShells } from "../../state/threads"; +import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; +import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../../state/olderSettledShelf"; import { pendingThreadOrderAtom, threadDropBusyAtom, @@ -28,7 +30,12 @@ import { threadDragAction, type ThreadMoveDestination, } from "./threadOrder"; -import { getThreadListV2OrderedSection, threadListInboxReturns } from "./threadListV2"; +import { + getThreadListV2OrderedSection, + THREAD_LIST_V2_SETTLED_INITIAL_COUNT, + THREAD_LIST_V2_SETTLED_PAGE_COUNT, + threadListInboxReturns, +} from "./threadListV2"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; const ROW_HEIGHT = 56; @@ -40,6 +47,8 @@ type Row = { key: string; section: Section; thread?: EnvironmentThreadShell; + /** The Settled section's "Show more" row. */ + more?: true; offset: number; height: number; }; @@ -158,6 +167,15 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { const { workingShelfEnabled } = useThreadListV2ShelfPreferences(); const [now, setNow] = useState(() => new Date().toISOString()); const [expanded, setExpanded] = useState({ snoozed: false, settled: false }); + const [settledVisibleCount, setSettledVisibleCount] = useState( + THREAD_LIST_V2_SETTLED_INITIAL_COUNT, + ); + // Older settled threads load in pages, as in the thread list. + const olderSettledShelf = useOlderSettledShelf({ + scopes: [...configs.keys()].map((environmentId) => ({ environmentId, projectIds: null })), + expanded: expanded.settled, + visibleCount: settledVisibleCount, + }); useEffect(() => { const wakeAt = Math.min( ...threads.flatMap((thread) => { @@ -201,9 +219,13 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { ? sortInboxThreadsByReturn(active, threadListInboxReturns.returnedAt) : active, snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })), - settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })), + settled: sortSettledThreads(parked.filter((thread) => !effectiveSnoozed(thread, { now }))), }; }, [threads, configs, now, queuedThreadKeys, pendingOrder, workingShelfEnabled]); + const listedSettled = sections.settled.filter(olderSettledShelf.isListed); + useOlderSettledShelfGrowth(olderSettledShelf, listedSettled); + const visibleSettled = listedSettled.slice(0, settledVisibleCount); + const settledCount = sections.settled.length + olderSettledShelf.unloadedCount(threads); const planners = useMemo(() => { const planner = (section: "pinned" | "active") => createThreadMovePlanner({ @@ -232,13 +254,17 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { result.push({ key: section, section, offset, height: HEADER_HEIGHT }); offset += HEADER_HEIGHT; if ((section === "snoozed" || section === "settled") && !expanded[section]) continue; - for (const thread of sections[section]) { + for (const thread of section === "settled" ? visibleSettled : sections[section]) { result.push({ key: keyOf(thread), section, thread, offset, height: ROW_HEIGHT }); offset += ROW_HEIGHT; } + if (section === "settled" && settledCount > visibleSettled.length) { + result.push({ key: "settled-more", section, more: true, offset, height: ROW_HEIGHT }); + offset += ROW_HEIGHT; + } } return result; - }, [sections, expanded]); + }, [sections, expanded, visibleSettled, settledCount]); const list = useRef>(null); const geometry = useRef({ height: 0, offset: 0 }); const drag = useRef(null); @@ -507,6 +533,25 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { }} /> + ) : item.more ? ( + { + olderSettledShelf.retry(); + setSettledVisibleCount( + (count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT, + ); + }} + > + + {olderSettledShelf.loading + ? "Loading" + : olderSettledShelf.failed + ? "Could not load older threads. Retry" + : `Show more (${settledCount - visibleSettled.length} settled hidden)`} + + ) : ( void }) { > {item.section[0]!.toUpperCase() + item.section.slice(1)} ( - {sections[item.section].length}) + {item.section === "settled" + ? settledCount + : sections[item.section].length} + ) )} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 2b6d7b306278..c7bde3c8d6b3 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -81,7 +81,12 @@ import { type SidebarListItem = | ThreadListV2ListItem - | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; + | { + readonly type: "v2-show-more"; + readonly key: string; + readonly hiddenCount: number; + readonly state?: "loading" | "failed"; + }; const SIDEBAR_STICKY_HEADER_HEIGHT = 106; @@ -182,10 +187,6 @@ function ThreadNavigationSidebarPane( ) ? [options.selectedEnvironmentId] : []; - const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); - // This map reaches LegendList's extraData, which re-renders every row whenever it changes - // identity; the hook keeps it stable. - const threadSearchMatchByKey = useThreadListSearchMatches(threads, threadSearch); const projectScopeKey = options.selectedProjectKey; const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { sidebarProjectGroupingMode: options.projectGroupingMode, @@ -226,6 +227,16 @@ function ThreadNavigationSidebarPane( ); const selectedProjectScope = projectScopes.find((scope) => scope.key === resolvedProjectKey) ?? null; + const threadSearch = useThreadSearch( + searchEnvironmentIds, + props.searchQuery, + selectedProjectScope === null + ? {} + : { projectIds: selectedProjectScope.projectRefs.map((ref) => ref.projectId) }, + ); + // This map reaches LegendList's extraData, which re-renders every row whenever it changes + // identity; the hook keeps it stable. + const threadSearchMatchByKey = useThreadListSearchMatches(threads, threadSearch); const selectedProjectKey = selectedProjectScope?.key ?? null; const projectByKey = useMemo(() => { const map = new Map(); @@ -283,6 +294,7 @@ function ThreadNavigationSidebarPane( layout: threadListV2Layout, items: threadListV2Items, contentOnlyThreadKeys, + olderSettledShelf, } = useThreadListV2({ threads, pendingTasks, @@ -326,6 +338,11 @@ function ThreadNavigationSidebarPane( type: "v2-show-more", key: "v2-show-more", hiddenCount: threadListV2Layout.hiddenSettledCount, + ...(olderSettledShelf.loading + ? { state: "loading" as const } + : olderSettledShelf.failed + ? { state: "failed" as const } + : {}), }, ] : threadListV2Items; @@ -472,7 +489,7 @@ function ThreadNavigationSidebarPane( return threadListV2ListItemsAreEqual(previous, item); } if (previous.type === "v2-show-more" && item.type === "v2-show-more") { - return previous.hiddenCount === item.hiddenCount; + return previous.hiddenCount === item.hiddenCount && previous.state === item.state; } return false; }; @@ -643,7 +660,11 @@ function ThreadNavigationSidebarPane( { + olderSettledShelf.retry(); + showMoreSettled(); + }} /> ); } diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 54d78769c448..7218e89f50c4 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -124,6 +124,7 @@ import { } from "./new-task-context-presentation"; import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; +import { useOlderSettledCount } from "../../state/olderSettledShelf"; type WorkspaceMode = "local" | "worktree"; @@ -511,13 +512,15 @@ export function NewTaskFlowProvider( t3ProjectFile, ); const canChooseWorkspace = !isScratchDraft; + const selectedProjectOlderSettledCount = useOlderSettledCount(selectedProject); const selectedProjectHasThreads = selectedProject !== null && - threads.some( - (thread) => - thread.environmentId === selectedProject.environmentId && - thread.projectId === selectedProject.id, - ); + (selectedProjectOlderSettledCount > 0 || + threads.some( + (thread) => + thread.environmentId === selectedProject.environmentId && + thread.projectId === selectedProject.id, + )); const defaultWorkspaceMode: WorkspaceMode = canChooseWorkspace ? resolveNewThreadEnvMode({ projectSettings, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 8dcdbecd600b..7018f9927898 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -287,12 +287,25 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS export const ThreadListV2ShowMoreRow = memo(function ThreadListV2ShowMoreRow(props: { readonly pane?: "screen" | "sidebar"; readonly hiddenCount: number; + /** Older settled threads are loading, or their last read failed. */ + readonly state?: "loading" | "failed"; readonly onPress: () => void; }) { + const label = + props.state === "loading" + ? "Loading" + : props.state === "failed" + ? "Could not load older threads. Retry" + : `Show more (${props.hiddenCount} settled hidden)`; return ( ({ opacity: pressed ? 0.6 : 1 })} @@ -304,7 +317,7 @@ export const ThreadListV2ShowMoreRow = memo(function ThreadListV2ShowMoreRow(pro : "text-xs font-t3-medium text-foreground-muted" } > - Show more ({props.hiddenCount} settled hidden) + {label} ); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 58d06bd5e696..c503b8d3de9c 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -548,6 +548,47 @@ describe("buildThreadListV2Items settled paging", () => { "settled-2", ]); }); + + it("lists only loaded settled rows and counts the threads the shell left out", () => { + const settled = (index: number) => + makeThread({ + id: ThreadId.make(`settled-${index}`), + title: `Settled ${index}`, + settledOverride: "settled", + settledAt: `2026-06-0${index}T00:00:00.000Z`, + }); + const threads = [settled(1), settled(2), settled(3)]; + const layout = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + settledLimit: 10, + now: NOW, + // settled-1 came back mid-session, below the loaded range; the route keeps it visible. + isListedSettled: (thread) => thread.id !== "settled-1", + unloadedSettledCount: 40, + selectedThreadKey: `${threads[0]!.environmentId}:settled-1`, + }); + expect(layout.items.map((item) => item.thread.id)).toEqual([ + "settled-3", + "settled-2", + "settled-1", + ]); + expect(layout.settledCount).toBe(43); + expect(layout.hiddenSettledCount).toBe(40); + + // A scope whose settled threads are all older still shows its Settled header. + const unloadedOnly = buildThreadListV2Items({ + threads: [], + environmentId: null, + searchQuery: "", + settledLimit: 10, + now: NOW, + unloadedSettledCount: 5, + }); + expect(unloadedOnly.settledCount).toBe(5); + expect(unloadedOnly.settledShelfHeaderIndex).toBe(0); + }); }); describe("buildThreadListV2Items settled sort reuse", () => { diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 2486d33fee89..b10427ef26d8 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -277,6 +277,8 @@ export interface ThreadListV2Item { export interface ThreadListV2Layout { readonly items: ThreadListV2Item[]; readonly hiddenSettledCount: number; + /** The settled rows the shelf may list, before its visible limit. */ + readonly listedSettledThreads: ReadonlyArray; readonly workingCount: number; readonly workingShelfHeaderIndex: number | null; readonly snoozedCount: number; @@ -582,6 +584,10 @@ export function buildThreadListV2Items(input: { readonly settledShelfExpanded?: boolean; readonly selectedThreadKey?: string | null; readonly queuedThreadKeys?: ReadonlySet; + /** Whether the shelf may list a settled row yet; older settled rows load in pages. */ + readonly isListedSettled?: (thread: EnvironmentThreadShell) => boolean; + /** Settled threads in scope that are not local (older settled threads not loaded). */ + readonly unloadedSettledCount?: number; }): ThreadListV2Layout { const now = input.now; const pending = @@ -694,6 +700,7 @@ export function buildThreadListV2Items(input: { isLast: index === results.length - 1, })), hiddenSettledCount: 0, + listedSettledThreads: [], workingCount: 0, workingShelfHeaderIndex: null, snoozedCount: 0, @@ -727,15 +734,21 @@ export function buildThreadListV2Items(input: { (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, ); const orderedSettled = sortSettledThreadsReusingLast(settled); + const listedSettled = + input.isListedSettled === undefined + ? orderedSettled + : orderedSettled.filter(input.isListedSettled); + const settledTotal = orderedSettled.length + (input.unloadedSettledCount ?? 0); const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; const limitedSettled = - orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled; + listedSettled.length > settledLimit ? listedSettled.slice(0, settledLimit) : listedSettled; const selectedSettled = - selectedThreadKey === null + selectedThreadKey === null || + limitedSettled.some((thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey) ? undefined - : orderedSettled - .slice(limitedSettled.length) - .find((thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey); + : orderedSettled.find( + (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, + ); const pagedSettled = selectedSettled === undefined ? limitedSettled : [...limitedSettled, selectedSettled]; const visibleSettled = @@ -792,7 +805,7 @@ export function buildThreadListV2Items(input: { isLast: false, }); } - const settledShelfHeaderIndex = orderedSettled.length > 0 ? items.length : null; + const settledShelfHeaderIndex = settledTotal > 0 ? items.length : null; for (const thread of visibleSettled) { items.push({ thread, @@ -809,12 +822,13 @@ export function buildThreadListV2Items(input: { } return { items, - hiddenSettledCount: orderedSettled.length - pagedSettled.length, + hiddenSettledCount: Math.max(0, settledTotal - pagedSettled.length), + listedSettledThreads: listedSettled, workingCount: orderedWorking.length, workingShelfHeaderIndex, snoozedCount: orderedSnoozed.length, snoozedShelfHeaderIndex, - settledCount: orderedSettled.length, + settledCount: settledTotal, settledShelfHeaderIndex, nextSnoozeWakeAt, contentOnlyThreadKeys, diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 7d3e795bf853..8498d907f690 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -50,7 +50,11 @@ import { useEffect, useRef, useState } from "react"; import type { ComposerEditorSelection } from "../../components/ComposerEditor"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; -import { useComposerPathSearch, useComposerPullRequestSearch } from "../../state/queries"; +import { + useComposerPathSearch, + useComposerPullRequestSearch, + useThreadSearch, +} from "../../state/queries"; import type { ComposerCommandItem } from "./ComposerCommandPopover"; import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; @@ -344,6 +348,12 @@ export function useComposerCommandMenu({ cwd: trigger?.kind === "path" ? projectCwd : null, query: trigger?.kind === "path" ? trigger.query : null, }); + // Mounts older settled threads matching the mention, which shells may leave out. + useThreadSearch( + trigger?.kind === "path" && environmentId ? [environmentId] : [], + trigger?.kind === "path" ? trigger.query : "", + { titlesOnly: true }, + ); const pullRequestSearch = useComposerPullRequestSearch({ environmentId, projectId: pullRequestProjectId, diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index 12d861d193a7..9c5cb77febab 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -1,7 +1,9 @@ +import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId } from "@t3tools/contracts"; import { useMemo } from "react"; import { scopedProjectKey } from "../../lib/scopedEntities"; +import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../../state/olderSettledShelf"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { buildThreadListV2Items, @@ -83,6 +85,14 @@ export function useThreadListV2(input: { ...sectionAvailability("pinned"), ...(workingShelfEnabled ? [] : sectionAvailability("active")), ]); + // Older settled threads load in pages; the shelf lists them only as far as they are loaded. + const scopes = olderSettledScopes(input); + const olderSettledShelf = useOlderSettledShelf({ + scopes, + expanded: input.settledShelfExpanded, + // A search lists only matches, so the shelf would page forever; loaded pages stay. + visibleCount: input.searchQuery.trim().length === 0 ? input.settledLimit : 0, + }); const layout = buildThreadListV2Items({ pendingOrder: input.pendingOrder, threads: threads.filter((thread) => thread.archivedAt === null), @@ -101,7 +111,20 @@ export function useThreadListV2(input: { snoozedShelfExpanded: input.snoozedShelfExpanded, settledShelfExpanded: input.settledShelfExpanded, selectedThreadKey: input.selectedThreadKey, + isListedSettled: olderSettledShelf.isListed, + unloadedSettledCount: olderSettledShelf.unloadedCount( + threads.filter( + (thread) => + thread.archivedAt === null && + scopes.some( + (scope) => + scope.environmentId === thread.environmentId && + (scope.projectIds === null || scope.projectIds.includes(thread.projectId)), + ), + ), + ), }); + useOlderSettledShelfGrowth(olderSettledShelf, layout.listedSettledThreads); const contentOnlyThreadKeys = useKeySet([...layout.contentOnlyThreadKeys].sort().join("\n")); const scopedProjectKeys = input.projectRefs == null @@ -135,5 +158,22 @@ export function useThreadListV2(input: { moveAvailability, shelfPreferencesLoading: !input.shelfPreferencesLoaded, }); - return { layout, items, contentOnlyThreadKeys }; + return { layout, items, contentOnlyThreadKeys, olderSettledShelf }; +} + +/** The list's scope per environment: its projects there, or every project. */ +function olderSettledScopes(input: { + readonly environmentId: EnvironmentId | null; + readonly projectRefs: LayoutInput["projectRefs"]; + readonly settlementEnvironmentIds: ReadonlySet; +}): ReadonlyArray { + const environmentIds = + input.environmentId === null ? [...input.settlementEnvironmentIds] : [input.environmentId]; + return environmentIds.flatMap((environmentId): ReadonlyArray => { + if (input.projectRefs == null) return [{ environmentId, projectIds: null }]; + const projectIds = input.projectRefs.flatMap((ref) => + ref.environmentId === environmentId ? [ref.projectId] : [], + ); + return projectIds.length === 0 ? [] : [{ environmentId, projectIds }]; + }); } diff --git a/apps/mobile/src/state/olderSettledShelf.ts b/apps/mobile/src/state/olderSettledShelf.ts new file mode 100644 index 000000000000..9c72393162ce --- /dev/null +++ b/apps/mobile/src/state/olderSettledShelf.ts @@ -0,0 +1,135 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + isListedSettledThread, + settledShelfFrontier, + unloadedOlderSettledCount, + wantedOlderSettledPages, + type OlderSettled, + type OlderSettledScope, +} from "@t3tools/client-runtime/state/threads"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import { useEffect, useState } from "react"; + +import { appAtomRegistry } from "./atom-registry"; +import { olderSettledThreads, threadFamilies } from "./threads"; + +const NO_PAGES: ReadonlyMap = new Map(); +const NO_OLDER_SETTLED: Atom.Atom = Atom.make(null); + +/** + * The Settled shelf for one scope when shells leave older settled threads out: which rows it may + * list, how many threads it holds in all, and the pages read below each environment's cut, whose + * rows merge into the thread list. `useOlderSettledShelfGrowth` reads the next pages. + */ +export function useOlderSettledShelf(input: { + readonly scopes: ReadonlyArray; + /** A collapsed shelf mounts no pages, so a reconnect reads none; expanding restores them. */ + readonly expanded: boolean; + /** Rows the shelf shows; pages are read until it lists that many. */ + readonly visibleCount: number; +}) { + const scopeKey = JSON.stringify(input.scopes); + const [requested, setRequested] = useState({ scopeKey, pages: NO_PAGES }); + const pages = requested.scopeKey === scopeKey && input.expanded ? requested.pages : NO_PAGES; + const shelfScopes = input.scopes.map((scope) => ({ + scope, + pages: pages.get(scope.environmentId) ?? 0, + })); + const shelfKey = JSON.stringify(shelfScopes); + const shelf = useAtomValue(olderSettledThreads.shelfAtom(shelfScopes)); + useEffect(() => { + const releases = (JSON.parse(shelfKey) as typeof shelfScopes).flatMap(({ scope, pages }) => + pages === 0 + ? [] + : [ + threadFamilies.mountRows( + appAtomRegistry, + scope.environmentId, + olderSettledThreads.rowsKey(scope, pages), + olderSettledThreads.pageRowsAtom(scope, pages), + ), + ], + ); + return () => { + for (const release of releases) release(); + }; + }, [shelfKey]); + + const olderOf = (environmentId: EnvironmentId) => shelf.get(environmentId)?.older ?? null; + const pagesOf = (environmentId: EnvironmentId) => shelf.get(environmentId)?.pages; + const frontier = settledShelfFrontier([...shelf.values()]); + const loaded = shelfScopes.filter(({ pages }) => pages > 0); + return { + environments: shelf, + visibleCount: input.expanded ? input.visibleCount : 0, + pages, + grow: (next: ReadonlyMap) => setRequested({ scopeKey, pages: next }), + isListed: (thread: EnvironmentThreadShell) => isListedSettledThread(thread.source, frontier), + /** Older settled threads in scope that are not local; add it to the local Settled rows. */ + unloadedCount: (threads: ReadonlyArray) => + unloadedOlderSettledCount({ olderSettled: olderOf, scopes: input.scopes, threads }), + loading: loaded.some(({ scope }) => pagesOf(scope.environmentId)?.status === "pending"), + failed: loaded.some(({ scope }) => pagesOf(scope.environmentId)?.status === "failed"), + retry: () => { + for (const { scope, pages } of loaded) { + olderSettledThreads.retryPages(appAtomRegistry, scope, pages); + } + }, + }; +} + +/** Reads the next pages while the shelf lists fewer rows than it shows. */ +export function useOlderSettledShelfGrowth( + shelf: ReturnType, + listedSettledThreads: ReadonlyArray, +) { + const wanted = wantedOlderSettledPages({ + requested: shelf.pages, + environments: shelf.environments, + listedCount: listedSettledThreads.length, + visibleCount: shelf.visibleCount, + }); + const { grow, pages } = shelf; + useEffect(() => { + if (wanted !== pages) grow(wanted); + }, [wanted, pages, grow]); +} + +/** + * Threads of these projects that this client does not hold (older settled threads), for counts + * shown outside the shelf, such as a removal confirm. + */ +export function readUnloadedThreadCount( + members: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly id: ProjectId }>, + threads: ReadonlyArray, +): number { + return unloadedOlderSettledCount({ + olderSettled: (environmentId) => + appAtomRegistry.get(olderSettledThreads.olderSettledAtom(environmentId)), + scopes: [...new Set(members.map((member) => member.environmentId))].map((environmentId) => ({ + environmentId, + projectIds: members.flatMap((member) => + member.environmentId === environmentId ? [member.id] : [], + ), + })), + threads: threads.filter((thread) => + members.some( + (member) => member.environmentId === thread.environmentId && member.id === thread.projectId, + ), + ), + }); +} + +/** How many older settled threads one project has, loaded or not, kept current for render. */ +export function useOlderSettledCount( + project: { readonly environmentId: EnvironmentId; readonly id: ProjectId } | null, +): number { + const older = useAtomValue( + project === null + ? NO_OLDER_SETTLED + : olderSettledThreads.olderSettledAtom(project.environmentId), + ); + return project === null ? 0 : (older?.counts.get(project.id) ?? 0); +} diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index a59dcd25e456..1665bf726ca5 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -20,6 +20,8 @@ import { visibleThreadSearch, type EnvironmentThreadSearchMatch, type SettledThreadSearch, + type ThreadSearchResultsState, + type ThreadSearchScope, } from "@t3tools/client-runtime/state/thread-search"; import { THREAD_SEARCH_QUERY_MAX_LENGTH } from "@t3tools/contracts"; import { messageSearchTokens } from "@t3tools/shared/paletteSearch"; @@ -32,6 +34,7 @@ import { useEffect, useState } from "react"; import { appAtomRegistry } from "./atom-registry"; import { orchestrationEnvironment } from "./orchestration"; +import { olderSettledThreads, threadFamilies } from "./threads"; import { projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; import { vcsEnvironment } from "./vcs"; @@ -49,17 +52,31 @@ const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); -const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ +const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ matches: EMPTY_THREAD_SEARCH_MATCHES, isLoading: false, + rows: [], }).pipe(Atom.withLabel("mobile:thread-search:empty")); -const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ - getSearchAtom: (environmentId, query) => - orchestrationEnvironment.threadSearch({ - environmentId, - input: { query }, - }), +const olderSettledBeforeAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => get(olderSettledThreads.olderSettledAtom(environmentId))?.before ?? null).pipe( + Atom.withLabel(`mobile:older-settled-before:${environmentId}`), + ), +); +// Whether any of these environments leaves older settled threads out, so short queries matter. +const anyOlderSettledAtom = Atom.family((environmentIdsKey: string) => + Atom.make((get) => + (JSON.parse(environmentIdsKey) as ReadonlyArray).some( + (environmentId) => get(olderSettledBeforeAtom(environmentId)) !== null, + ), + ).pipe(Atom.withLabel(`mobile:any-older-settled:${environmentIdsKey}`)), +); +const threadSearch = createThreadSearchResultsAtomFamily({ + getSearchAtom: (environmentId, input) => + input.olderSettledBefore === undefined + ? orchestrationEnvironment.threadSearch({ environmentId, input }) + : olderSettledThreads.searchAtom(environmentId, input), + olderSettledBefore: olderSettledBeforeAtom, labelPrefix: "mobile:thread-search", }); @@ -163,9 +180,15 @@ export function useComposerPullRequestSearch(input: { }; } +/** + * Server matches for a thread search: message hits, and on environments that leave older settled + * threads out of the shell, the rows of the best older matches, mounted into the thread list while + * they show so the caller's own ranking finds them. + */ export function useThreadSearch( environmentIds: ReadonlyArray, query: string, + scope: ThreadSearchScope = {}, ): { readonly matches: ReadonlyArray; /** The query `matches` are for, which lags the typed one while a search runs. */ @@ -175,20 +198,25 @@ export function useThreadSearch( // A pasted wall of text searches by its start rather than failing the request. const normalizedQuery = query.trim().slice(0, THREAD_SEARCH_QUERY_MAX_LENGTH).trim(); const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS); - // The server needs a word of three or more letters; shorter queries would always come back empty. + const anyOlderSettled = useAtomValue(anyOlderSettledAtom(JSON.stringify(environmentIds))); + // Messages need a word of three or more letters; older settled titles match from two. const canSearch = - environmentIds.length > 0 && messageSearchTokens(normalizedQuery).long.length > 0; + environmentIds.length > 0 && + ((scope.titlesOnly !== true && messageSearchTokens(normalizedQuery).long.length > 0) || + (anyOlderSettled && normalizedQuery.length >= 2)); const settledQuery = canSearch && normalizedQuery === debouncedQuery ? debouncedQuery : null; - const searchKey = - settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery); + const searchKeyOf = (searchQuery: string) => + makeThreadSearchKey(environmentIds, searchQuery, scope); + const searchKey = settledQuery === null ? null : searchKeyOf(settledQuery); const result = useAtomValue( - searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey), + searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearch.results(searchKey), ); const isPending = canSearch && (normalizedQuery !== debouncedQuery || result.isLoading); // What fast environments returned shows without waiting for a slow one; // callers keep shown rows in place with `nextShownOrder`. const ready = - settledQuery !== null && (!result.isLoading || result.matches.length > 0) + settledQuery !== null && + (!result.isLoading || result.matches.length > 0 || result.rows.length > 0) ? { query: settledQuery, matches: result.matches } : null; // Kept from earlier renders: React's pattern for information from previous renders. @@ -197,6 +225,23 @@ export function useThreadSearch( const visible = canSearch ? visibleThreadSearch(normalizedQuery, ready, previous) : { query: "", matches: EMPTY_THREAD_SEARCH_MATCHES }; + // The shown results' rows stay mounted until newer results replace them. + const shownKey = visible.query === "" ? null : searchKeyOf(visible.query); + useEffect(() => { + if (shownKey === null) return; + const environments = (JSON.parse(shownKey) as [ReadonlyArray])[0]; + const releases = environments.map((environmentId) => + threadFamilies.mountRows( + appAtomRegistry, + environmentId, + `search:${shownKey}`, + threadSearch.rows(shownKey, environmentId), + ), + ); + return () => { + for (const release of releases) release(); + }; + }, [shownKey]); return { matches: visible.matches, query: visible.query, diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 2b7d4db5b99d..8b00fdf1963b 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -5,6 +5,7 @@ import { createEnvironmentThreadStateAtoms, EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState, + createOlderSettledThreadAtoms, createThreadEnvironmentAtoms, createThreadFamilyAtoms, } from "@t3tools/client-runtime/state/threads"; @@ -22,6 +23,10 @@ export const threadFamilies = createThreadFamilyAtoms( connectionAtomRuntime, environmentShell.stateAtom, ); +export const olderSettledThreads = createOlderSettledThreadAtoms( + connectionAtomRuntime, + environmentShell.stateAtom, +); export const threadEnvironment = createThreadEnvironmentAtoms( connectionAtomRuntime, threadFamilies.snapshotAtom, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 44dc45c87387..1e3e2658bcc9 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -50,6 +50,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds]: AuthOrchestrationReadScope, + [ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.launchThread]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index d524aa396b01..4a23878e4a8a 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -23,6 +23,7 @@ const RPC_AGGREGATES = { [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.launchThread]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: "orchestration", diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 13ae0a48fa13..8ab9e8eae232 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -133,6 +133,8 @@ import { type ProjectionRecordFilter, type ProjectionRecords, type ProjectionCheckpointContext, + type OlderSettledThreadsListing, + type OlderSettledThreadsQuery, type ShellSnapshotOptions, } from "./ProjectionStore.ts"; import { ProviderAdapterRegistryV2 } from "./ProviderAdapterRegistry.ts"; @@ -388,6 +390,9 @@ export interface OrchestratorV2Shape { ReadonlyArray, OrchestratorV2Error >; + readonly listOlderSettledThreads: ( + query: OlderSettledThreadsQuery, + ) => Effect.Effect; readonly readShellSnapshot: ( options?: ShellSnapshotOptions, ) => Effect.Effect< @@ -12473,6 +12478,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projectionStore.getShellSnapshot(options).pipe(Effect.mapError(shellProjectionError)), listActiveSubagentThreadIds: () => projectionStore.listActiveSubagentThreadIds().pipe(Effect.mapError(shellProjectionError)), + listOlderSettledThreads: (query) => + projectionStore.listOlderSettledThreads(query).pipe(Effect.mapError(shellProjectionError)), readShellSnapshot: (options) => projectionStore .readShellSnapshot(options) @@ -12624,6 +12631,13 @@ const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + listOlderSettledThreads: () => + Effect.fail( + new OrchestratorProjectionError({ + threadId: ThreadId.make("thread:shell"), + cause: "Orchestration V2 live runtime is not configured.", + }), + ), readShellSnapshot: () => Effect.fail( new OrchestratorProjectionError({ diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 251716acb446..a8dedfeec280 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1166,11 +1166,12 @@ it.effect.each([ const family = (threadId: ThreadId) => store - .getShellSnapshot({ location: "active", subagentFamilyOf: threadId }) + .getShellSnapshot({ location: "active", familyOf: threadId }) .pipe(Effect.map((snapshot) => snapshot.threads.map((thread) => thread.id))); - // Any member, or the root itself, names the same active subagent tree. - assert.sameMembers([...(yield* family(id("root")))], [id("child"), id("grandchild")]); - assert.sameMembers([...(yield* family(id("grandchild")))], [id("child"), id("grandchild")]); + // Any member, or the root itself, names the same active tree, root included. + const tree = [id("root"), id("child"), id("grandchild")]; + assert.sameMembers([...(yield* family(id("root")))], tree); + assert.sameMembers([...(yield* family(id("grandchild")))], tree); assert.deepEqual([...(yield* family(id("missing")))], []); // Includes another root's idle child on the same path, which a shell may leave out. @@ -1191,6 +1192,102 @@ it.effect.each([ }).pipe(Effect.provide(layer)), ); +it.effect.each([ + ["memory", ProjectionStore.layerMemory], + ["SQL", layerTest], +] as const)("%s list older settled threads below a cursor", ([, layer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const id = (name: string) => ThreadId.make(`thread:older:${name}`); + const at = (day: number) => DateTime.makeUnsafe(Date.UTC(2026, 9, day)); + const createThread = (input: { + readonly name: string; + readonly settledDay: number | null; + readonly project?: string; + readonly subagent?: boolean; + readonly archived?: boolean; + readonly snoozedUntilDay?: number; + }) => + store.apply({ + id: EventId.make(`event:older:${input.name}`), + type: "thread.created", + threadId: id(input.name), + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: id(input.name), + projectId: ProjectId.make(input.project ?? "project:a"), + title: input.name, + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: input.subagent === true ? id("b") : null, + relationshipToParent: input.subagent === true ? "subagent" : null, + rootThreadId: input.subagent === true ? id("b") : id(input.name), + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: input.archived === true ? now : null, + settledOverride: input.settledDay === null ? null : "settled", + settledAt: input.settledDay === null ? null : at(input.settledDay), + snoozedUntil: input.snoozedUntilDay === undefined ? null : at(input.snoozedUntilDay), + lastVisitedAt: null, + deletedAt: null, + }, + }); + yield* createThread({ name: "a", settledDay: 5 }); + yield* createThread({ name: "b", settledDay: 4 }); + yield* createThread({ name: "c", settledDay: 4 }); + yield* createThread({ name: "d", settledDay: 3, project: "project:b" }); + yield* createThread({ name: "snoozed", settledDay: 2, snoozedUntilDay: 30 }); + yield* createThread({ name: "recent", settledDay: 20 }); + yield* createThread({ name: "unsettled", settledDay: null }); + yield* createThread({ name: "child", settledDay: 1, subagent: true }); + yield* createThread({ name: "archived", settledDay: 1, archived: true }); + + const list = ( + before: { readonly settledAt: string; readonly threadId?: ThreadId }, + limit: number, + projectIds?: ReadonlyArray, + ) => store.listOlderSettledThreads({ before, projectIds, limit }); + const before = { settledAt: DateTime.formatIso(at(10)) }; + const first = yield* list(before, 2); + assert.deepEqual(first, { threadIds: [id("a"), id("b")], counts: [] }); + assert.sameDeepMembers( + [...(yield* list(before, 0)).counts], + [ + { projectId: ProjectId.make("project:a"), count: 4 }, + { projectId: ProjectId.make("project:b"), count: 1 }, + ], + ); + // A cursor without an id is strict, as the snapshot's cut is: the day-4 tie is not counted. + assert.sameDeepMembers( + [...(yield* list({ settledAt: DateTime.formatIso(at(4)) }, 0)).counts], + [ + { projectId: ProjectId.make("project:a"), count: 1 }, + { projectId: ProjectId.make("project:b"), count: 1 }, + ], + ); + // The cursor is the last row: the tie on day 4 continues by id. + const next = yield* list({ settledAt: DateTime.formatIso(at(4)), threadId: id("b") }, 10); + assert.deepEqual(next.threadIds, [id("c"), id("d"), id("snoozed")]); + assert.deepEqual(yield* list(before, 25, []), { threadIds: [], counts: [] }); + const scoped = yield* list(before, 0, [ProjectId.make("project:b")]); + assert.deepEqual(scoped, { + threadIds: [], + counts: [{ projectId: ProjectId.make("project:b"), count: 1 }], + }); + }).pipe(Effect.provide(layer)), +); + it.effect("lists only active subagent IDs without loading shell data", () => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 60b5de0edb5d..700395facd79 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -26,6 +26,8 @@ import type { PlanId, OrchestrationV2ConversationMessage, OrchestrationV2DomainEvent, + OrchestrationV2OlderSettledCount, + OrchestrationV2OlderSettledCursor, OrchestrationV2ProjectedTurnItem, OrchestrationV2ProviderThread, OrchestrationV2ProviderTurn, @@ -37,7 +39,6 @@ import type { OrchestrationV2ThreadProjection, OrchestrationV2TurnItem, GuestShareId, - ProjectId, ProviderSessionId, ProviderThreadId, ProviderTurnId, @@ -71,6 +72,7 @@ import { ThreadId, TurnItemId, NodeId, + ProjectId, ProviderInstanceId, } from "@t3tools/contracts"; import { @@ -410,11 +412,24 @@ export interface ShellSnapshotOptions { readonly projectId?: ProjectId; readonly unsettledOnly?: boolean; /** - * Only the subagent threads sharing this thread's `lineage.rootThreadId`. The thread may be - * deleted: forks keep a deleted source's root, and their subagent trees still need it. + * Only the threads sharing this thread's `lineage.rootThreadId`: the root, its forks, and their + * subagent trees. The thread may be deleted: forks keep a deleted source's root. */ - readonly subagentFamilyOf?: ThreadId; + readonly familyOf?: ThreadId; readonly worktreePath?: string; + readonly threadIds?: ReadonlyArray; +} + +export interface OlderSettledThreadsQuery { + readonly before: OrchestrationV2OlderSettledCursor; + readonly projectIds?: ReadonlyArray | undefined; + readonly limit: number; +} + +export interface OlderSettledThreadsListing { + readonly threadIds: ReadonlyArray; + /** Only for `limit: 0`. */ + readonly counts: ReadonlyArray; } export interface ProjectionStoreV2Shape { @@ -461,6 +476,10 @@ export interface ProjectionStoreV2Shape { ReadonlyArray, ProjectionStoreV2Error >; + /** Older settled thread ids below a cursor, newest settle first, and per-project counts. */ + readonly listOlderSettledThreads: ( + query: OlderSettledThreadsQuery, + ) => Effect.Effect; /** * Callers that combine the shell with other reads run this inside their own * transaction and the returned decode after it commits, so the shared @@ -5718,8 +5737,9 @@ export const layer: Layer.Layer = location, projectId, unsettledOnly = false, - subagentFamilyOf, + familyOf, worktreePath, + threadIds, }: ShellSnapshotOptions = {}, ) => sql` @@ -5922,18 +5942,18 @@ export const layer: Layer.Layer = ? sql` AND json_extract(t.payload_json, '$.settledAt') IS NULL AND json_extract(t.payload_json, '$.settledOverride') IS NOT 'settled'` : sql`` }${ - subagentFamilyOf === undefined + familyOf === undefined ? sql`` - : sql` AND json_extract(t.payload_json, '$.lineage.relationshipToParent') = 'subagent' AND json_extract(t.payload_json, '$.lineage.rootThreadId') = ( + : sql` AND json_extract(t.payload_json, '$.lineage.rootThreadId') = ( SELECT json_extract(member.payload_json, '$.lineage.rootThreadId') FROM orchestration_v2_projection_threads member - WHERE member.thread_id = ${subagentFamilyOf} + WHERE member.thread_id = ${familyOf} )` }${ worktreePath === undefined ? sql`` : sql` AND json_extract(t.payload_json, '$.worktreePath') = ${worktreePath}` - } + }${threadIds === undefined ? sql`` : sql` AND t.thread_id IN ${sql.in(threadIds)}`} ORDER BY t.updated_at ASC, t.thread_id ASC `; @@ -6577,6 +6597,55 @@ export const layer: Layer.Layer = return rows[0] === undefined ? null : ThreadId.make(rows[0].thread_id); }, Effect.mapError(shellSnapshotReadError)); + const listOlderSettledThreads: ProjectionStoreV2Shape["listOlderSettledThreads"] = Effect.fn( + "ProjectionStore.listOlderSettledThreads", + )(function* ({ before, projectIds, limit }) { + // ISO strings in one format, so string order is time order. + const older = sql` + SELECT t.thread_id, t.project_id, + json_extract(t.payload_json, '$.settledAt') AS settled_at + FROM orchestration_v2_projection_threads t + WHERE t.deleted_at IS NULL + AND json_extract(t.payload_json, '$.archivedAt') IS NULL + AND json_extract(t.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(t.payload_json, '$.settledOverride') = 'settled' + AND json_extract(t.payload_json, '$.settledAt') IS NOT NULL${ + projectIds === undefined ? sql`` : sql` AND t.project_id IN ${sql.in(projectIds)}` + } + `; + const belowCursor = + before.threadId === undefined + ? sql`older.settled_at < ${before.settledAt}` + : sql`(older.settled_at < ${before.settledAt} + OR (older.settled_at = ${before.settledAt} AND older.thread_id > ${before.threadId}))`; + const pageRows = + limit === 0 + ? [] + : yield* sql<{ readonly thread_id: string }>` + WITH older AS (${older}) + SELECT older.thread_id FROM older + WHERE ${belowCursor} + ORDER BY older.settled_at DESC, older.thread_id ASC + LIMIT ${limit} + `; + const countRows = + limit !== 0 + ? [] + : yield* sql<{ readonly project_id: string; readonly count: number }>` + WITH older AS (${older}) + SELECT older.project_id, COUNT(*) AS count FROM older + WHERE ${belowCursor} + GROUP BY older.project_id + `; + return { + threadIds: pageRows.map((row) => ThreadId.make(row.thread_id)), + counts: countRows.map((row) => ({ + projectId: ProjectId.make(row.project_id), + count: row.count, + })), + }; + }, Effect.mapError(shellSnapshotReadError)); + const getThreadShell: ProjectionStoreV2Shape["getThreadShell"] = (threadId) => sql .withTransaction( @@ -6671,6 +6740,7 @@ export const layer: Layer.Layer = getShellSnapshot: readOnReader(getShellSnapshot), listActiveSubagentThreadIds: readOnReader(listActiveSubagentThreadIds), readShellSnapshot: readOnReader(readShellSnapshot), + listOlderSettledThreads: readOnReader(listOlderSettledThreads), getThreadShell: readOnReader(getThreadShell), findGuestShareThreadId: readOnReader(findGuestShareThreadId), getThread: readOnReader(getThread), @@ -6761,16 +6831,21 @@ export const layerMemory: Layer.Layer = Layer.effect( ) { return false; } - if (options?.subagentFamilyOf !== undefined) { - const member = existing.get(options.subagentFamilyOf)?.thread; + if (options?.familyOf !== undefined) { + const member = existing.get(options.familyOf)?.thread; if ( member === undefined || - projection.thread.lineage.relationshipToParent !== "subagent" || projection.thread.lineage.rootThreadId !== member.lineage.rootThreadId ) { return false; } } + if ( + options?.threadIds !== undefined && + !options.threadIds.includes(projection.thread.id) + ) { + return false; + } if ( options?.worktreePath !== undefined && projection.thread.worktreePath !== options.worktreePath @@ -6808,6 +6883,52 @@ export const layerMemory: Layer.Layer = Layer.effect( .map(({ thread }) => thread.id); }, ), + listOlderSettledThreads: Effect.fn("ProjectionStore.listOlderSettledThreads")(function* ({ + before, + projectIds, + limit, + }) { + const older = [...(yield* Ref.get(replayState)).projections.values()].flatMap( + ({ thread }) => { + if ( + thread.deletedAt !== null || + thread.archivedAt !== null || + thread.lineage.relationshipToParent === "subagent" || + thread.settledOverride !== "settled" || + thread.settledAt === null || + (projectIds !== undefined && !projectIds.includes(thread.projectId)) + ) { + return []; + } + const settledAt = DateTime.formatIso(thread.settledAt); + const below = + settledAt < before.settledAt || + (before.threadId !== undefined && + settledAt === before.settledAt && + thread.id > before.threadId); + return below ? [{ thread, settledAt }] : []; + }, + ); + const counts = new Map(); + for (const { thread } of limit === 0 ? older : []) { + counts.set(thread.projectId, (counts.get(thread.projectId) ?? 0) + 1); + } + return { + threadIds: older + .toSorted((left, right) => + left.settledAt !== right.settledAt + ? left.settledAt > right.settledAt + ? -1 + : 1 + : left.thread.id < right.thread.id + ? -1 + : 1, + ) + .slice(0, limit) + .map(({ thread }) => thread.id), + counts: Array.from(counts, ([projectId, count]) => ({ projectId, count })), + }; + }), readShellSnapshot: (options) => service.getShellSnapshot(options).pipe(Effect.map(Effect.succeed)), getThreadShell: Effect.fn("ProjectionStore.getThreadShell")(function* (threadId) { diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 42646e974ec0..4837c4a2ac1c 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -212,6 +212,7 @@ it.effect( getLimitRecoveryCandidates: () => Effect.die("unused getLimitRecoveryCandidates"), getShellSnapshot: () => Effect.die("unused getShellSnapshot"), listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), + listOlderSettledThreads: () => Effect.die("unused listOlderSettledThreads"), readShellSnapshot: () => Effect.die("unused readShellSnapshot"), getThreadShell: () => Effect.die("unused getThreadShell"), findGuestShareThreadId: () => Effect.die("unused findGuestShareThreadId"), diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index 229400dd8ce9..e308c74a00ff 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -22,6 +22,7 @@ import { composeShellStreamWithEnrichment, dedupeShellEnrichment, omitIdleSubagentShells, + omitOlderSettledShells, shellStreamItemFromEnrichmentRefresh, shellStreamItemsFromInitialSnapshot, shouldPublishShellApplicationEvent, @@ -204,6 +205,93 @@ describe("omitIdleSubagentShells", () => { }); }); +describe("omitOlderSettledShells", () => { + const DAY = 24 * 60 * 60 * 1000; + const now = DateTime.makeUnsafe(100 * DAY); + const row = ( + id: string, + settledDaysAgo: number | null, + overrides: Partial = {}, + ) => + shellFixture({ + id: ThreadId.make(id), + projectId: ProjectId.make(id.startsWith("b-") ? "project-b" : "project-a"), + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: ThreadId.make(id), + }, + status: "completed", + activityRunStatus: null, + pendingRuntimeRequest: null, + pendingBackgroundTasks: [], + settledOverride: settledDaysAgo === null ? null : "settled", + settledAt: + settledDaysAgo === null ? null : DateTime.makeUnsafe(100 * DAY - settledDaysAgo * DAY), + ...overrides, + }); + const newest = Array.from({ length: 25 }, (_, index) => row(`new-${index}`, index / 10)); + const omit = (threads: ReadonlyArray) => + omitOlderSettledShells({ threads, snapshotSequence: 7, now }); + + it("keeps the 25 newest settled threads and counts the rest per project", () => { + const result = omit([...newest, row("old", 30), row("b-old", 40), row("unsettled", null)]); + expect(result.threads.map((thread) => thread.id)).toEqual([ + ...newest.map((thread) => thread.id), + "unsettled", + ]); + expect(result.olderSettled).toEqual({ + before: DateTime.formatIso(DateTime.makeUnsafe(100 * DAY - 2.4 * DAY)), + asOfSequence: 7, + counts: [ + { projectId: "project-a", count: 1 }, + { projectId: "project-b", count: 1 }, + ], + }); + }); + + it("keeps threads tied with the 25th newest", () => { + const result = omit([...newest, row("tie", 2.4), row("older", 2.5)]); + expect(result.threads.map((thread) => thread.id).slice(25)).toEqual(["tie"]); + }); + + it("omits nothing with fewer than 25 settled threads, however old", () => { + const old = Array.from({ length: 5 }, (_, index) => row(`old-${index}`, 300 + index)); + const result = omit(old); + expect(result.threads).toEqual(old); + expect(result.olderSettled.counts).toEqual([]); + }); + + it("keeps older rows that are snoozed, live, or above a kept subagent, and still counts them", () => { + const threads = [ + ...newest, + row("snoozed", 30, { snoozedUntil: DateTime.makeUnsafe(101 * DAY) }), + row("woke", 30, { snoozedUntil: DateTime.makeUnsafe(99 * DAY) }), + row("running", 30, { activityRunStatus: "running" }), + row("parent", 30), + shellFixture({ + id: ThreadId.make("child"), + projectId: ProjectId.make("project-a"), + lineage: { + parentThreadId: ThreadId.make("parent"), + relationshipToParent: "subagent", + rootThreadId: ThreadId.make("parent"), + }, + settledOverride: null, + settledAt: null, + }), + ]; + const result = omit(threads); + expect(result.threads.map((thread) => thread.id).slice(25)).toEqual([ + "snoozed", + "running", + "parent", + "child", + ]); + expect(result.olderSettled.counts).toEqual([{ projectId: "project-a", count: 4 }]); + }); +}); + describe("shellStreamItemsFromInitialSnapshot", () => { it("keeps thread rows out of the metadata-only enrichment frame", () => { const snapshot = { diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 6952dfd2a9cb..99ac2b2d89d7 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -2,14 +2,19 @@ import type { ApplicationStoredEvent, OrchestrationProjectShell, OrchestrationV2ArchivedShellStreamItem, + OrchestrationV2OlderSettledCount, + OrchestrationV2OlderSettledThreads, OrchestrationV2ShellSnapshot, OrchestrationV2ThreadShell, OrchestrationV2ThreadShellSnapshot, OrchestrationV2ShellStreamItem, OrchestrationV2StoredEvent, + ProjectId, + ThreadId, } from "@t3tools/contracts"; import { isLiveSubagentShell, + isOlderSettledThreadShell, OrchestrationProjectShell as ProjectShellSchema, OrchestrationV2ThreadShell as ThreadShellSchema, } from "@t3tools/contracts"; @@ -17,6 +22,7 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import type * as Arr from "effect/Array"; import type * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -108,6 +114,93 @@ export function activeShellRowsForSubscriber( }; } +const NEWEST_SETTLED_KEPT = 25; + +/** + * Opted-in snapshots keep the 25 newest settled top-level threads (and ties) and count the rest per + * project in `olderSettled`; clients page them with `getOlderSettledThreads`. Older rows stay while + * pinned, snoozed, live, or above a kept subagent, so walks over shell rows still find them. + */ +export function omitOlderSettledShells(input: { + readonly threads: ReadonlyArray; + readonly snapshotSequence: number; + readonly now: DateTime.Utc; +}): { + readonly threads: ReadonlyArray; + readonly olderSettled: OrchestrationV2OlderSettledThreads; +} { + const nowMs = DateTime.toEpochMillis(input.now); + const settledTimes = input.threads + .flatMap((thread) => + thread.lineage.relationshipToParent !== "subagent" && + thread.settledOverride === "settled" && + thread.settledAt !== null + ? [DateTime.toEpochMillis(thread.settledAt)] + : [], + ) + .sort((left, right) => right - left); + // With fewer than 25, the oldest one is the cut, so nothing is older. + const beforeMs = settledTimes[NEWEST_SETTLED_KEPT - 1] ?? settledTimes.at(-1) ?? nowMs; + const ancestorIds = new Set(); + for (const thread of input.threads) { + if (thread.lineage.relationshipToParent !== "subagent") continue; + if (thread.lineage.parentThreadId !== null) ancestorIds.add(thread.lineage.parentThreadId); + ancestorIds.add(thread.lineage.rootThreadId); + } + const counts = new Map(); + const threads = input.threads.filter((thread) => { + if (!isOlderSettledThreadShell(thread, beforeMs)) return true; + counts.set(thread.projectId, (counts.get(thread.projectId) ?? 0) + 1); + return ( + thread.pinnedAt != null || + (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs) || + isLiveSubagentShell(thread) || + ancestorIds.has(thread.id) + ); + }); + return { + threads, + olderSettled: { + before: DateTime.formatIso(DateTime.makeUnsafe(beforeMs)), + asOfSequence: input.snapshotSequence, + counts: Array.from(counts, ([projectId, count]) => ({ projectId, count })), + }, + }; +} + +/** + * The snapshot a shell subscriber gets for its opt-ins, and the subagent ids whose transcript + * events its stream drops (from every row, so an omitted child that wakes keeps that filter). + */ +export function shellSnapshotForSubscriber( + base: OrchestrationV2ShellSnapshot, + options: { + readonly omitIdleSubagents: boolean; + readonly omitOlderSettled: boolean; + readonly now: DateTime.Utc; + }, +): { + readonly snapshot: OrchestrationV2ShellSnapshot; + readonly subagentThreadIds: Set; +} { + const rows = activeShellRowsForSubscriber(base.threads, options.omitIdleSubagents); + if (!options.omitOlderSettled) { + return { + snapshot: { ...base, threads: rows.threads }, + subagentThreadIds: rows.subagentThreadIds, + }; + } + const older = omitOlderSettledShells({ + threads: rows.threads, + snapshotSequence: base.snapshotSequence, + now: options.now, + }); + return { + snapshot: { ...base, threads: older.threads, olderSettled: older.olderSettled }, + subagentThreadIds: rows.subagentThreadIds, + }; +} + /** * One transaction covers the reads so they agree; the threads are decoded after it commits, * because the server shares one SQLite connection and decoding a large shell takes longer than @@ -140,6 +233,45 @@ export const loadShellSnapshotParts = Effect.fn("ShellStream.loadShellSnapshotPa }; }); +/** A page of older settled threads, read and decoded like `loadShellSnapshotParts`. */ +export const loadOlderSettledThreadsPage = Effect.fn("ShellStream.loadOlderSettledThreadsPage")( + function* (input: { + readonly sql: SqlClient.SqlClient; + readonly listThreads: Effect.Effect< + { + readonly threadIds: ReadonlyArray; + readonly counts: ReadonlyArray; + }, + E1 + >; + readonly readThreads: ( + threadIds: ReadonlyArray, + ) => Effect.Effect, E2>; + readonly latestSequence: Effect.Effect; + }) { + const read = yield* input.sql + .withTransaction( + Effect.gen(function* () { + const listed = yield* input.listThreads; + return { + listed, + decodeThreads: + listed.threadIds.length === 0 ? null : yield* input.readThreads(listed.threadIds), + snapshotSequence: yield* input.latestSequence, + }; + }), + ) + .pipe(NodeSqliteClient.readOnly); + const threads = read.decodeThreads === null ? [] : (yield* read.decodeThreads).threads; + const order = new Map(read.listed.threadIds.map((threadId, index) => [threadId, index])); + return { + snapshotSequence: read.snapshotSequence, + threads: threads.toSorted((left, right) => order.get(left.id)! - order.get(right.id)!), + counts: read.listed.counts, + }; + }, +); + export type ShellApplicationEvent = | Pick< Extract, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 17ad6c6bbc6f..18500427a103 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -384,8 +384,9 @@ export interface ThreadManagementServiceShape { readonly location?: "active" | "archive"; }) => Effect.Effect; readonly listActiveSubagentThreadIds: Orchestrator.OrchestratorV2["Service"]["listActiveSubagentThreadIds"]; + readonly listOlderSettledThreads: Orchestrator.OrchestratorV2["Service"]["listOlderSettledThreads"]; readonly readShellSnapshot: ( - options?: Pick, + options?: Pick, ) => Effect.Effect< Effect.Effect, Orchestrator.OrchestratorV2Error @@ -1243,6 +1244,7 @@ const make = Effect.gen(function* () { getProjectThread, getShellSnapshot: orchestrator.getShellSnapshot, listActiveSubagentThreadIds: orchestrator.listActiveSubagentThreadIds, + listOlderSettledThreads: orchestrator.listOlderSettledThreads, readShellSnapshot: orchestrator.readShellSnapshot, getThreadShell: orchestrator.getThreadShell, listProjectThreads, diff --git a/apps/server/src/orchestration-v2/ThreadSearch.test.ts b/apps/server/src/orchestration-v2/ThreadSearch.test.ts index 13042a32846b..399943c7b1b9 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.test.ts @@ -6,6 +6,7 @@ import { ProviderInstanceId, ThreadId, type OrchestrationV2DomainEvent, + type ThreadLinkedPullRequest, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as DateTime from "effect/DateTime"; @@ -65,6 +66,9 @@ const thread = ( readonly deletedAt?: DateTime.Utc; readonly subagentOf?: ThreadId; readonly title?: string; + readonly settledAt?: DateTime.Utc; + readonly linkedPullRequest?: ThreadLinkedPullRequest; + readonly branchPullRequest?: ThreadLinkedPullRequest; } = {}, ): OrchestrationV2DomainEvent => ({ id: EventId.make(`created:${threadId}`), @@ -84,6 +88,8 @@ const thread = ( interactionMode: "default", branch: null, worktreePath: null, + linkedPullRequest: overrides.linkedPullRequest ?? null, + branchPullRequest: overrides.branchPullRequest ?? null, activeProviderThreadId: null, lineage: overrides.subagentOf ? { @@ -96,8 +102,8 @@ const thread = ( createdAt: at(0), updatedAt: at(0), archivedAt: overrides.archivedAt ?? null, - settledOverride: null, - settledAt: null, + settledOverride: overrides.settledAt === undefined ? null : "settled", + settledAt: overrides.settledAt ?? null, lastVisitedAt: null, deletedAt: overrides.deletedAt ?? null, }, @@ -662,6 +668,162 @@ it.layer(layerTest)("ThreadSearch across scripts and without the index", (it) => ); }); +it.layer(layerTest)("ThreadSearch.searchOlderSettled", (it) => { + it.effect("returns older settled title matches in scope, then older message hits", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:kestrel"); + const other = ProjectId.make("project:kestrel-other"); + yield* createProject(project); + yield* createProject(other); + const id = (name: string) => ThreadId.make(`thread:kestrel-${name}`); + const old = at(10); + yield* Effect.forEach( + [ + thread(id("old"), project, { title: "Kestrel nest", settledAt: old }), + thread(id("recent"), project, { title: "Kestrel nest", settledAt: at(50) }), + thread(id("open"), project, { title: "Kestrel nest" }), + thread(id("child"), project, { title: "Kestrel nest", subagentOf: id("old") }), + thread(id("elsewhere"), other, { title: "Kestrel nest", settledAt: old }), + thread(id("note"), project, { title: "Unrelated", settledAt: old }), + thread(id("new-note"), project, { title: "Unrelated", settledAt: at(50) }), + ], + projections.apply, + { discard: true }, + ); + const before = DateTime.formatIso(at(30)); + const messageThreadIds = [id("new-note"), id("note"), id("old")]; + assert.sameMembers( + [...(yield* search.searchOlderSettled({ query: "kestrel", before, messageThreadIds }))], + [id("old"), id("elsewhere"), id("note")], + ); + // The scope applies to titles and message hits; message hits follow the titles in order. + assert.deepEqual( + yield* search.searchOlderSettled({ + query: "kestrel", + before, + projectIds: [project], + messageThreadIds: [id("elsewhere"), ...messageThreadIds], + }), + [id("old"), id("note")], + ); + // A palette also matches the environment's name. + assert.include( + yield* search.searchOlderSettled({ + query: "laptop unrelated", + before, + environmentLabel: "Laptop", + messageThreadIds: [], + }), + id("note"), + ); + }), + ); + + it.effect("matches the branch and linked pull requests, like the palette", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:heron"); + yield* createProject(project); + const pullRequest = (number: number): ThreadLinkedPullRequest => ({ + projectId: project, + repository: "acme/heron", + number, + url: `https://github.com/acme/heron/pull/${number}`, + }); + const branch = ThreadId.make("thread:heron-branch"); + const linked = ThreadId.make("thread:heron-linked"); + yield* Effect.forEach( + [ + thread(branch, project, { + title: "Heron", + settledAt: at(10), + branchPullRequest: pullRequest(4101), + }), + thread(linked, project, { + title: "Heron", + settledAt: at(10), + linkedPullRequest: pullRequest(4202), + }), + ], + projections.apply, + { discard: true }, + ); + const find = (query: string) => + search.searchOlderSettled({ + query, + before: DateTime.formatIso(at(30)), + messageThreadIds: [], + }); + assert.deepEqual(yield* find("#4101"), [branch]); + assert.deepEqual(yield* find("acme/heron#4202"), [linked]); + }), + ); +}); + +it.layer(layerTest)("ThreadSearch.searchThreadsWithOlderSettledRows", (it) => { + it.effect("adds older settled rows to message hits, or alone for titles only", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const sql = yield* SqlClient.SqlClient; + const project = ProjectId.make("project:plover"); + yield* createProject(project); + const old = ThreadId.make("thread:plover-old"); + const recent = ThreadId.make("thread:plover-recent"); + yield* Effect.forEach( + [ + thread(old, project, { title: "Plover nest", settledAt: at(10) }), + thread(recent, project, { title: "Plover nest", settledAt: at(50) }), + message(recent, "plover-note", "user", "a plover note", { minute: 2 }), + ], + projections.apply, + { discard: true }, + ); + const run = (request: { readonly titlesOnly?: boolean }) => + ThreadSearch.searchThreadsWithOlderSettledRows({ + request: { query: "plover", olderSettledBefore: DateTime.formatIso(at(30)), ...request }, + search: search.search, + searchOlderSettled: search.searchOlderSettled, + sql, + readThreads: (threadIds) => + projections.readShellSnapshot({ location: "active", threadIds }), + latestSequence: Effect.succeed(7), + }); + const withMessages = yield* run({}); + assert.deepEqual( + withMessages.matches.map((match) => match.threadId), + [recent], + ); + assert.equal(withMessages.snapshotSequence, 7); + assert.deepEqual( + withMessages.threads?.map((row) => row.id), + [old], + ); + const titles = yield* run({ titlesOnly: true }); + assert.deepEqual(titles.matches, []); + assert.deepEqual( + titles.threads?.map((row) => row.id), + [old], + ); + // Without the cut it is the plain message search. + assert.isUndefined( + (yield* ThreadSearch.searchThreadsWithOlderSettledRows({ + request: { query: "plover" }, + search: search.search, + searchOlderSettled: search.searchOlderSettled, + sql, + readThreads: (threadIds) => + projections.readShellSnapshot({ location: "active", threadIds }), + latestSequence: Effect.succeed(7), + })).threads, + ); + }), + ); +}); + it.layer(layerTest)("ThreadSearch.searchThreads", (it) => { it.effect("keeps room for a message-only thread behind many titled ones", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadSearch.ts b/apps/server/src/orchestration-v2/ThreadSearch.ts index 34f2a64e6c2a..37a1b9e726f9 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.ts @@ -4,8 +4,13 @@ import { OrchestrationThreadSearchSource, OrchestrationV2AppThreadJson, OrchestrationV2ThreadOpens, + ThreadLinkedPullRequest, + ThreadPullRequestLink, type OrchestrationSearchThreadsInput, type OrchestrationSearchThreadsResult, + type OrchestrationV2SearchThreadsInput, + type OrchestrationV2SearchThreadsResult, + type OrchestrationV2ThreadShellSnapshot, ProjectId, ThreadId, } from "@t3tools/contracts"; @@ -29,6 +34,7 @@ import { } from "@t3tools/shared/paletteSearch"; import { threadSearchPullRequests } from "@t3tools/shared/threadPullRequests"; import { indexedMessageCondition } from "./messageSearchIndex.ts"; +import { loadOlderSettledThreadsPage } from "./ShellStream.ts"; // When the user last wrote in the thread aliased `threads`, as the clients // rank recency; agent notices in the user role do not count. @@ -252,9 +258,40 @@ export class ThreadSearch extends Context.Service< readonly searchThreads: ( input: OrchestrationSearchThreadsInput & { readonly projectId?: ProjectId | undefined }, ) => Effect.Effect<{ readonly matches: ReadonlyArray }, ThreadSearchError>; + /** + * The settled threads before `before` that clients omitting older settled threads should + * receive for a query: the best title, branch, pull request and project matches, ranked as + * the palette ranks them, then the older ones among `messageThreadIds`, in their order. + */ + readonly searchOlderSettled: (input: { + readonly query: string; + readonly before: string; + readonly projectIds?: ReadonlyArray | undefined; + /** The caller's name for this environment, which its palette also matches. */ + readonly environmentLabel?: string | undefined; + readonly messageThreadIds: ReadonlyArray; + }) => Effect.Effect, ThreadSearchError>; } >()("t3/orchestration-v2/ThreadSearch") {} +// Rows a client gets per search: enough for the shown results without sending every match. +const OLDER_SETTLED_TITLE_ROWS = 15; +const OLDER_SETTLED_MESSAGE_ROWS = 5; + +// Only the fields the palette scorer reads, so an interactive search does not decode payloads. +const OlderSettledTitleRow = Schema.Struct({ + id: ThreadId, + title: Schema.String, + branch: Schema.NullOr(Schema.String), + pullRequests: Schema.NullOr(Schema.fromJsonString(Schema.Array(ThreadPullRequestLink))), + linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), + branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), + opens: Schema.NullOr(Schema.String), + createdAt: IsoDateTime, + projectTitle: Schema.String, + latestUserMessageAt: Schema.NullOr(IsoDateTime), +}); + /** @public */ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -298,6 +335,61 @@ export const make = Effect.gen(function* () { `, }); + const olderSettledTitleCandidates = SqlSchema.findAll({ + Request: Schema.Struct({ + before: Schema.String, + projectIds: Schema.NullOr(TokenList), + }), + Result: OlderSettledTitleRow, + execute: ({ before, projectIds }) => sql` + SELECT + threads.thread_id AS id, + json_extract(threads.payload_json, '$.title') AS title, + json_extract(threads.payload_json, '$.branch') AS branch, + json_extract(threads.payload_json, '$.pullRequests') AS "pullRequests", + json_extract(threads.payload_json, '$.linkedPullRequest') AS "linkedPullRequest", + json_extract(threads.payload_json, '$.branchPullRequest') AS "branchPullRequest", + json_extract(threads.payload_json, '$.opens') AS opens, + json_extract(threads.payload_json, '$.createdAt') AS "createdAt", + projects.title AS "projectTitle", + ${latestUserMessageAt(sql)} AS "latestUserMessageAt" + FROM orchestration_v2_projection_threads AS threads + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + WHERE threads.deleted_at IS NULL + AND json_extract(threads.payload_json, '$.archivedAt') IS NULL + AND projects.deleted_at IS NULL + AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') + IS NOT 'subagent' + AND json_extract(threads.payload_json, '$.settledOverride') = 'settled' + AND json_extract(threads.payload_json, '$.settledAt') < ${before} + AND (${projectIds} IS NULL + OR threads.project_id IN (SELECT value FROM json_each(${projectIds}))) + `, + }); + + const olderSettledIds = SqlSchema.findAll({ + Request: Schema.Struct({ + before: Schema.String, + threadIds: TokenList, + projectIds: Schema.NullOr(TokenList), + }), + Result: Schema.Struct({ id: ThreadId }), + execute: ({ before, threadIds, projectIds }) => sql` + SELECT threads.thread_id AS id + FROM orchestration_v2_projection_threads AS threads + WHERE threads.thread_id IN (SELECT value FROM json_each(${threadIds})) + AND threads.deleted_at IS NULL + AND json_extract(threads.payload_json, '$.archivedAt') IS NULL + AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') + IS NOT 'subagent' + AND json_extract(threads.payload_json, '$.settledOverride') = 'settled' + AND json_extract(threads.payload_json, '$.settledAt') < ${before} + AND (${projectIds} IS NULL + OR threads.project_id IN (SELECT value FROM json_each(${projectIds}))) + `, + }); + const read = (effect: Effect.Effect) => sql.withTransaction(effect).pipe( NodeSqliteClient.readOnly, @@ -478,7 +570,98 @@ export const make = Effect.gen(function* () { }; }); - return ThreadSearch.of({ search, searchThreads }); + const searchOlderSettled: ThreadSearch["Service"]["searchOlderSettled"] = Effect.fn( + "ThreadSearch.searchOlderSettled", + )(function* ({ query, before, projectIds, environmentLabel, messageThreadIds }) { + const normalizedQuery = foldSearchQuery(query); + const now = DateTime.toEpochMillis(yield* DateTime.now); + const candidates = yield* read( + olderSettledTitleCandidates({ before, projectIds: projectIds ?? null }), + ); + const subjectOf = (row: typeof OlderSettledTitleRow.Type): ThreadSearchSubject => ({ + id: row.id, + title: row.title, + branch: row.branch, + projectTitle: row.projectTitle, + environmentLabel: environmentLabel ?? null, + pullRequests: threadSearchPullRequests({ + pullRequests: row.pullRequests ?? undefined, + linkedPullRequest: row.linkedPullRequest, + branchPullRequest: row.branchPullRequest, + }), + // Like the clients: what the user last wrote, else when the thread began. + activityAt: Date.parse(row.latestUserMessageAt ?? row.createdAt), + opens: Option.getOrNull(decodeThreadOpens(row.opens)), + }); + // Picks on, as in the command palette: a thread picked for this query must make the cut. + const titleIds = rankSearchItems(candidates, (row, options) => + scoreThreadSearch(normalizedQuery, subjectOf(row), now, { ...options, picks: true }), + ) + .slice(0, OLDER_SETTLED_TITLE_ROWS) + .map(({ item }) => item.id); + const shown = new Set(titleIds); + const messageCandidates = [...new Set(messageThreadIds)].filter((id) => !shown.has(id)); + const older = + messageCandidates.length === 0 + ? new Set() + : new Set( + (yield* read( + olderSettledIds({ + before, + threadIds: messageCandidates, + projectIds: projectIds ?? null, + }), + )).map((row) => row.id), + ); + return [ + ...titleIds, + ...messageCandidates.filter((id) => older.has(id)).slice(0, OLDER_SETTLED_MESSAGE_ROWS), + ]; + }); + + return ThreadSearch.of({ search, searchThreads, searchOlderSettled }); }); export const layer = Layer.effect(ThreadSearch, make); + +/** + * The client search: message hits, and with `olderSettledBefore`, the rows of the best older + * settled matches, which shells that omit them do not hold. + */ +export const searchThreadsWithOlderSettledRows = Effect.fn( + "ThreadSearch.searchThreadsWithOlderSettledRows", +)(function* (input: { + readonly request: OrchestrationV2SearchThreadsInput; + readonly search: ( + input: OrchestrationSearchThreadsInput, + ) => Effect.Effect; + readonly searchOlderSettled: ( + input: Parameters[0], + ) => Effect.Effect, E2>; + readonly sql: SqlClient.SqlClient; + readonly readThreads: ( + threadIds: ReadonlyArray, + ) => Effect.Effect, E3>; + readonly latestSequence: Effect.Effect; +}) { + const { olderSettledBefore, projectIds, environmentLabel, titlesOnly, ...request } = + input.request; + const messages: OrchestrationV2SearchThreadsResult = + titlesOnly === true ? { matches: [] } : yield* input.search(request); + if (olderSettledBefore === undefined) return messages; + // Ranked before the rows' transaction, so scoring never holds the shared reader. + const threadIds = yield* input.searchOlderSettled({ + query: request.query, + before: olderSettledBefore, + projectIds, + environmentLabel, + messageThreadIds: messages.matches.map((match) => match.threadId), + }); + const older = yield* loadOlderSettledThreadsPage({ + sql: input.sql, + listThreads: Effect.succeed({ threadIds, counts: [] }), + readThreads: input.readThreads, + latestSequence: input.latestSequence, + }); + return { ...messages, snapshotSequence: older.snapshotSequence, threads: older.threads }; +}); diff --git a/apps/server/src/orchestration-v2/http.test.ts b/apps/server/src/orchestration-v2/http.test.ts index 74476d658ed0..76dd8575797d 100644 --- a/apps/server/src/orchestration-v2/http.test.ts +++ b/apps/server/src/orchestration-v2/http.test.ts @@ -96,9 +96,9 @@ const items = Schema.decodeUnknownSync(Schema.Array(OrchestrationV2TurnItem))( })), ); -it.effect("exports full history with size, scope, and missing-thread checks", () => +// Serves the orchestration HTTP routes over real stores; `allowed` gates the read scope. +const serveOrchestrationHttp = (allowed: () => boolean) => Effect.gen(function* () { - let allowed = true; const runtime = ProviderReplayHarness.layerWithRegistry( { name: "thread-transcript" }, ProviderAdapterRegistry.layerFromAdapters([]), @@ -143,7 +143,7 @@ it.effect("exports full history with size, scope, and missing-thread checks", () sessionId: AuthSessionId.make("test-session"), subject: "test", method: "bearer-access-token", - scopes: new Set(allowed ? ["orchestration:read"] : []), + scopes: new Set(allowed() ? ["orchestration:read"] : []), }), ), ), @@ -157,6 +157,13 @@ it.effect("exports full history with size, scope, and missing-thread checks", () ); const { handler, dispose } = HttpRouter.toWebHandler(routes, { disableLogger: true }); yield* Effect.addFinalizer(() => Effect.promise(dispose)); + return { services, eventSink, handler }; + }); + +it.effect("exports full history with size, scope, and missing-thread checks", () => + Effect.gen(function* () { + let allowed = true; + const { services, eventSink, handler } = yield* serveOrchestrationHttp(() => allowed); const read = (threadId: ThreadId) => Effect.promise(() => handler( @@ -242,3 +249,48 @@ it.effect("exports full history with size, scope, and missing-thread checks", () expect((yield* read(thread.id)).status).toBe(403); }), ); + +it.effect("leaves older settled threads out of the HTTP shell only when asked", () => + Effect.gen(function* () { + const { eventSink, handler } = yield* serveOrchestrationHttp(() => true); + yield* eventSink.write({ + events: Array.from({ length: 26 }, (_, index) => { + const id = ThreadId.make(`settled-${index}`); + return { + id: EventId.make(`create-${id}`), + type: "thread.created" as const, + threadId: id, + occurredAt: now, + payload: { + ...thread, + id, + lineage: { ...thread.lineage, rootThreadId: id }, + settledOverride: "settled" as const, + settledAt: DateTime.makeUnsafe(Date.UTC(2026, 8, 1 + index)), + }, + }; + }), + }); + const shell = (query: string) => + Effect.promise(async () => { + const response = await handler( + new Request(`http://localhost/api/orchestration/shell${query}`, { + headers: { [ORCHESTRATION_PROTOCOL_HEADER]: ORCHESTRATION_PROTOCOL_VERSION_TEXT }, + }), + ); + return (await response.json()) as { + readonly threads: ReadonlyArray<{ readonly id: string }>; + readonly olderSettled?: { readonly counts: ReadonlyArray }; + }; + }); + + const full = yield* shell(""); + expect(full.threads).toHaveLength(27); + expect(full.olderSettled).toBeUndefined(); + const cut = yield* shell("?omitOlderSettled=1"); + expect(cut.threads.map((row) => row.id).toSorted()).toEqual( + [thread.id, ...Array.from({ length: 25 }, (_, index) => `settled-${index + 1}`)].toSorted(), + ); + expect(cut.olderSettled?.counts).toEqual([{ projectId: thread.projectId, count: 1 }]); + }), +); diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index 772fa1710b9b..373c40e0787e 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -3,6 +3,7 @@ import { EnvironmentHttpApi, type OrchestrationProjectShell, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; @@ -33,7 +34,7 @@ import * as ProjectStore from "./ProjectStore.ts"; import { buildActiveShellSnapshot, loadShellSnapshotParts, - omitIdleSubagentShells, + shellSnapshotForSubscriber, } from "./ShellStream.ts"; import { boundedSnapshotResponseFields } from "./ThreadStream.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; @@ -77,24 +78,27 @@ export const layer = HttpApiBuilder.group( ), ); - const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")(function* ( - omitIdleSubagents: boolean, - ) { - const base = buildActiveShellSnapshot( - yield* loadShellSnapshotParts({ - sql, - readThreads: threadManagement.readShellSnapshot({ location: "active" }), - listProjects: projectStore.listShells(), - latestSequence: applicationEvents.latestApplicationSequence, - }), - ); - const projects = yield* enrichProjectShells(base.projects); - return { - ...base, - threads: omitIdleSubagents ? omitIdleSubagentShells(base.threads) : base.threads, - projects, - }; - }); + const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")( + function* (options: { + readonly omitIdleSubagents: boolean; + readonly omitOlderSettled: boolean; + }) { + const base = buildActiveShellSnapshot( + yield* loadShellSnapshotParts({ + sql, + readThreads: threadManagement.readShellSnapshot({ location: "active" }), + listProjects: projectStore.listShells(), + latestSequence: applicationEvents.latestApplicationSequence, + }), + ); + const { snapshot } = shellSnapshotForSubscriber(base, { + ...options, + now: yield* DateTime.now, + }); + const projects = yield* enrichProjectShells(base.projects); + return { ...snapshot, projects }; + }, + ); const loadThreadSnapshot = Effect.fn("http.orchestration.loadThreadSnapshot")(function* ( threadId: Parameters[0], @@ -162,7 +166,10 @@ export const layer = HttpApiBuilder.group( Effect.fn("environment.orchestration.shellSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); - return yield* loadShellSnapshot(args.query.omitIdleSubagents === "1").pipe( + return yield* loadShellSnapshot({ + omitIdleSubagents: args.query.omitIdleSubagents === "1", + omitOlderSettled: args.query.omitOlderSettled === "1", + }).pipe( traceLocalHandlerWork, Effect.catch((cause) => failEnvironmentInternal("orchestration_snapshot_failed", cause), diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index b5f400dc3432..a879dc98c6c7 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -188,6 +188,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( return { schemaVersion: 2, snapshotSequence: 1, threads: [], archivedThreads: [] }; }), listActiveSubagentThreadIds: unused, + listOlderSettledThreads: unused, readShellSnapshot: unused, ensureLegacyTranscript: unused, dispatch: unused, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index ea18643a6881..a2c3910d4da5 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -14,6 +14,7 @@ import { type ServerConfigStreamEvent, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; @@ -292,33 +293,45 @@ it.live.each(["thread", "shell"] as const)( }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); -it.effect("shell snapshots leave idle subagent children out only for clients that ask", () => +it.effect("shell snapshots omit idle children and older settled threads only on request", () => Effect.gen(function* () { - const shell = (id: string, parent: string | null, activityRunStatus: "running" | null) => + const shell = ( + id: string, + parent: string | null, + activityRunStatus: "running" | null, + settledDaysAgo: number | null = null, + ) => ({ id: ThreadId.make(id), + projectId: "project", archivedAt: null, status: activityRunStatus ?? "completed", activityRunStatus, pendingRuntimeRequest: null, pendingBackgroundTasks: [], + settledOverride: settledDaysAgo === null ? null : "settled", + settledAt: + settledDaysAgo === null ? null : DateTime.makeUnsafe(-settledDaysAgo * 86_400_000), lineage: { parentThreadId: parent === null ? null : ThreadId.make(parent), relationshipToParent: parent === null ? null : "subagent", - rootThreadId: ThreadId.make("parent"), + rootThreadId: ThreadId.make(parent ?? id), }, }) as unknown as OrchestrationV2ThreadShell; const threads = [ shell("parent", null, null), shell("idle", "parent", null), shell("live", "parent", "running"), + ...Array.from({ length: 25 }, (_, index) => shell(`recent-${index}`, null, null, 1)), + shell("old", null, null, 30), ]; - const firstSnapshotThreadIds = (input: { readonly omitIdleSubagents?: boolean }) => + const firstSnapshot = (input: { + readonly omitIdleSubagents?: boolean; + readonly omitOlderSettled?: boolean; + }) => subscribeOrchestrationV2Shell(input).pipe( Effect.flatMap((stream) => stream.pipe(Stream.take(1), Stream.runCollect)), - Effect.map(([item]) => - item?.kind === "snapshot" ? item.snapshot.threads.map((thread) => thread.id) : [], - ), + Effect.map(([item]) => (item?.kind === "snapshot" ? item.snapshot : null)), Effect.provideService(ThreadManagement.ThreadManagementService, { readShellSnapshot: () => Effect.succeed( @@ -349,10 +362,25 @@ it.effect("shell snapshots leave idle subagent children out only for clients tha ), ); - assert.deepEqual(yield* firstSnapshotThreadIds({}), ["parent", "idle", "live"]); - assert.deepEqual(yield* firstSnapshotThreadIds({ omitIdleSubagents: true }), [ - "parent", - "live", + const recent = threads.slice(3, 28).map((thread) => thread.id); + const full = yield* firstSnapshot({}); + assert.deepEqual( + full?.threads.map((thread) => thread.id), + threads.map((thread) => thread.id), + ); + assert.isUndefined(full?.olderSettled); + const withoutChildren = yield* firstSnapshot({ omitIdleSubagents: true }); + assert.deepEqual( + withoutChildren?.threads.map((thread) => thread.id), + ["parent", "live", ...recent, "old"], + ); + const withoutOld = yield* firstSnapshot({ omitIdleSubagents: true, omitOlderSettled: true }); + assert.deepEqual( + withoutOld?.threads.map((thread) => thread.id), + ["parent", "live", ...recent], + ); + assert.deepEqual(withoutOld?.olderSettled?.counts, [ + { projectId: ProjectId.make("project"), count: 1 }, ]); }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 69204e9cf798..4341cd926d39 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -131,11 +131,12 @@ import { coalesceStoredThreadEvents, composeShellStreamWithEnrichment, dedupeShellEnrichment, - activeShellRowsForSubscriber, + loadOlderSettledThreadsPage, loadShellSnapshotParts, shellStreamItemFromEnrichmentRefresh, shellStreamItemFromThreadShell, shellStreamItemsFromInitialSnapshot, + shellSnapshotForSubscriber, shellStreamItemsFromResumeSnapshot, shouldPublishShellApplicationEvent, skipUnchangedThreadShells, @@ -989,6 +990,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; readonly omitIdleSubagents?: boolean; + readonly omitOlderSettled?: boolean; }) { const sql = yield* SqlClient.SqlClient; const threadManagement = yield* ThreadManagementService.ThreadManagementService; @@ -1025,13 +1027,16 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc latestSequence: applicationEvents.latestApplicationSequence, }), ); - const rows = activeShellRowsForSubscriber(base.threads, input.omitIdleSubagents === true); - subagentThreadIds = rows.subagentThreadIds; + const forSubscriber = shellSnapshotForSubscriber(base, { + omitIdleSubagents: input.omitIdleSubagents === true, + omitOlderSettled: input.omitOlderSettled === true, + now: yield* DateTime.now, + }); + subagentThreadIds = forSubscriber.subagentThreadIds; const enriched = yield* enrichProjectShells(base.projects); return { snapshot: { - ...base, - threads: rows.threads, + ...forSubscriber.snapshot, projects: enriched.projects, } as OrchestrationV2ShellSnapshot, resolvedRepositoryIdentityRoots: enriched.resolvedRepositoryIdentityRoots, @@ -1778,6 +1783,7 @@ export const layerWsRpc = ( settings, shellResumeCompletionMarker: true, shellThreadFamilies: true, + shellOlderSettled: true, threadResumeCompletionMarker: true, threadSnapshotPagination: true, threadFind: true, @@ -1827,7 +1833,7 @@ export const layerWsRpc = ( // Rows the shell can omit, read with the same snapshot sequence the shell uses. const readOmittableShellRows = Effect.fn("ws.orchestrationV2.readOmittableShellRows")( function* ( - options: { readonly subagentFamilyOf: ThreadId } | { readonly worktreePath: string }, + options: { readonly familyOf: ThreadId } | { readonly worktreePath: string }, message: string, ) { const { threads, snapshotSequence } = yield* loadShellSnapshotParts({ @@ -1981,7 +1987,15 @@ export const layerWsRpc = ( .searchThreadStream(input) .pipe(Stream.mapError((cause) => new OrchestrationV2SearchThreadError({ cause }))), [ORCHESTRATION_V2_WS_METHODS.searchThreads]: (input) => - threadSearch.search(input).pipe( + ThreadSearch.searchThreadsWithOlderSettledRows({ + request: input, + search: threadSearch.search, + searchOlderSettled: threadSearch.searchOlderSettled, + sql, + readThreads: (threadIds) => + threadManagement.readShellSnapshot({ location: "active", threadIds }), + latestSequence: applicationEvents.latestApplicationSequence, + }).pipe( Effect.mapError( (cause) => new OrchestrationSearchThreadsError({ @@ -1993,9 +2007,22 @@ export const layerWsRpc = ( [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: (_input) => getOrchestrationV2ArchivedShellSnapshot, [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: (input) => - readOmittableShellRows( - { subagentFamilyOf: input.threadId }, - "Failed to load the thread family", + readOmittableShellRows({ familyOf: input.threadId }, "Failed to load the thread family"), + [ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads]: (input) => + loadOlderSettledThreadsPage({ + sql, + listThreads: threadManagement.listOlderSettledThreads(input), + readThreads: (threadIds) => + threadManagement.readShellSnapshot({ location: "active", threadIds }), + latestSequence: applicationEvents.latestApplicationSequence, + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationV2GetShellSnapshotError({ + message: "Failed to load older settled threads", + cause, + }), + ), ), [ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds]: (input) => readOmittableShellRows( diff --git a/apps/web/src/components/CommandPaletteThreadItems.tsx b/apps/web/src/components/CommandPaletteThreadItems.tsx index 41f62a1119ce..7f3238d199ad 100644 --- a/apps/web/src/components/CommandPaletteThreadItems.tsx +++ b/apps/web/src/components/CommandPaletteThreadItems.tsx @@ -68,10 +68,25 @@ function useProviderEntryByEnvironmentAndInstanceId() { function useConnectedThreadSearch(query: string) { const { environments } = useEnvironments(); + const { projectEnvironmentLocationById } = useEnvironmentLookups(); const environmentIds: ReadonlyArray = environments .filter((environment) => environment.connection.phase === "connected") .map((environment) => environment.environmentId); - return useThreadSearch(environmentIds, query); + // The palette matches environment names when there are several; older rows must match the same. + const environmentLabels = + environments.length > 1 + ? Object.fromEntries( + environmentIds.flatMap((environmentId) => { + const label = projectEnvironmentLocationById.get(environmentId)?.label; + return label === undefined || label === null ? [] : [[environmentId, label]]; + }), + ) + : undefined; + return useThreadSearch( + environmentIds, + query, + environmentLabels === undefined ? {} : { environmentLabels }, + ); } /** diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index ae85add1c9b9..cb3484e097f8 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -128,6 +128,7 @@ import { useDesktopUpdateState } from "../state/desktopUpdate"; import { useThreadActions } from "../hooks/useThreadActions"; import { projectEnvironment } from "../state/projects"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { readUnloadedThreadCount } from "../state/olderSettledShelf"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { buildThreadRouteParams, resolveActiveThreadRouteRef } from "../threadRoutes"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -1487,7 +1488,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } const memberProjectRef = scopeProjectRef(member.environmentId, member.id); - const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0; + const memberThreadCount = + (memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) + + readUnloadedThreadCount( + [{ environmentId: member.environmentId, id: member.id }], + sidebarThreads, + ); if (memberThreadCount > 0) { const warningToastId = toastManager.add( stackedThreadToast({ @@ -1510,11 +1516,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec thread.environmentId === memberProjectRef.environmentId && thread.projectId === memberProjectRef.projectId, ); + const threadCount = + latestProjectThreads.length + + readUnloadedThreadCount( + [{ environmentId: member.environmentId, id: member.id }], + latestProjectThreads, + ); const confirmed = await api.dialogs.confirm( - latestProjectThreads.length > 0 + threadCount > 0 ? [ - `Remove project "${member.title}" and delete its ${latestProjectThreads.length} thread${ - latestProjectThreads.length === 1 ? "" : "s" + `Remove project "${member.title}" and delete its ${threadCount} thread${ + threadCount === 1 ? "" : "s" }?`, `Path: ${member.workspaceRoot}`, ...(member.environmentLabel diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8d40d5f01a48..6d9773dcc71d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -50,9 +50,11 @@ import { } from "@t3tools/client-runtime/environment"; import { AuthOrchestrationOperateScope, + ProjectId, type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; +import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads"; import { FolderIcon, ListFilterIcon, PlusIcon, SettingsIcon } from "lucide-react"; import { @@ -129,6 +131,7 @@ import { environmentPresentations } from "../state/presentation"; import { primaryServerKeybindingsAtom, serverEnvironment } from "../state/server"; import { environmentProjects } from "../state/projects"; import { threadEnvironment } from "../state/threads"; +import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../state/olderSettledShelf"; import { useThreadSearch } from "../state/queries"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { readEnvironmentScope } from "../state/session"; @@ -421,16 +424,28 @@ function useSidebarThreads(input: { const { pinnedThreads, activeThreads, workingThreads, snoozedThreads, settledThreads } = partition; + // Older settled threads load in pages; the shelf lists them only as far as they are loaded. + const olderSettledShelf = useOlderSettledShelf({ + scopes: olderSettledScopes([...serverConfigs.keys()], scopedProjectKeys), + expanded: settledShelfExpanded, + visibleCount: settledVisibleCount, + }); + const listedSettledThreads = settledThreads.filter(olderSettledShelf.isListed); + useOlderSettledShelfGrowth(olderSettledShelf, listedSettledThreads); + const settledCount = + settledThreads.length + olderSettledShelf.unloadedCount(partition.visibleThreads); const visibleSettledThreads = (() => { - if (settledThreads.length <= settledVisibleCount) return settledThreads; - const visible = settledThreads.slice(0, settledVisibleCount); - if (routeRowThreadKey !== null) { - const routeThread = settledThreads - .slice(settledVisibleCount) - .find((thread) => threadShellKey(thread) === routeRowThreadKey); + const visible = listedSettledThreads.slice(0, settledVisibleCount); + if ( + routeRowThreadKey !== null && + !visible.some((thread) => threadShellKey(thread) === routeRowThreadKey) + ) { + const routeThread = settledThreads.find( + (thread) => threadShellKey(thread) === routeRowThreadKey, + ); if (routeThread !== undefined) visible.push(routeThread); } - return visible; + return visible.length === settledThreads.length ? settledThreads : visible; })(); const renderedSettledThreads = settledShelfExpanded ? visibleSettledThreads @@ -471,7 +486,9 @@ function useSidebarThreads(input: { ...settledThreads, ], settledVisibleCount, - hiddenSettledCount: settledThreads.length - visibleSettledThreads.length, + settledCount, + olderSettledShelf, + hiddenSettledCount: Math.max(0, settledCount - visibleSettledThreads.length), showMoreSettled: () => setSettledTail((current) => ({ scopeKey: current.scopeKey, @@ -589,10 +606,28 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { firstValidTimestampMs(right.snoozedUntil ?? null), ), settledThreads: sortSettledThreads(settled), + visibleThreads: visible, snoozeNow: preciseNow, }; } +/** The sidebar's scope per environment: the scoped projects there, or every project. */ +function olderSettledScopes( + environmentIds: ReadonlyArray, + scopedProjectKeys: ReadonlySet | null, +): ReadonlyArray { + if (scopedProjectKeys === null) { + return environmentIds.map((environmentId) => ({ environmentId, projectIds: null })); + } + return environmentIds.flatMap((environmentId) => { + const prefix = `${environmentId}:`; + const projectIds = [...scopedProjectKeys].flatMap((key) => + key.startsWith(prefix) ? [ProjectId.make(key.slice(prefix.length))] : [], + ); + return projectIds.length === 0 ? [] : [{ environmentId, projectIds }]; + }); +} + function isOptimisticDropReleased(input: { readonly optimisticDrop: SidebarOptimisticDrop; readonly threads: readonly EnvironmentThreadShell[]; @@ -861,7 +896,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [searchHighlightKey, setSearchHighlightKey] = useState(null); const searchEnvironmentIds = useConnectedEnvironmentIds(); - const threadSearch = useThreadSearch(searchEnvironmentIds, threadSearchQuery); const handleNewThreadRef = useRef(newThreadContext.handleNewThread); const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); @@ -939,6 +973,17 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, ), ); + const threadSearch = useThreadSearch( + searchEnvironmentIds, + threadSearchQuery, + scopedProjectGroup === null + ? {} + : { + projectIds: scopedProjectGroup.memberProjectRefs.map( + (projectRef) => projectRef.projectId, + ), + }, + ); useEffect(() => { const nextKey = resolveSidebarProjectScopeKey({ groups: projectGroups, @@ -1024,6 +1069,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve snoozeNow, searchableThreads, settledVisibleCount, + settledCount, + olderSettledShelf, hiddenSettledCount, showMoreSettled, settledShelfExpanded, @@ -1659,7 +1706,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve activeThreads.length + workingThreads.length + snoozedThreads.length + - settledThreads.length === + settledCount === 0 ) { return []; @@ -3115,9 +3162,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve workingThreads.length + snoozedThreads.length === 0 && "mt-auto", )} label={ - settledShelfExpanded - ? "Settled" - : `Settled (${settledThreads.length})` + settledShelfExpanded ? "Settled" : `Settled (${settledCount})` } dragging={from !== null} isDropTarget={dragTargetSection === "settled"} @@ -3154,11 +3199,19 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
  • ) : null} @@ -3173,7 +3226,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve activeThreads.length + workingThreads.length + snoozedThreads.length + - settledThreads.length === + settledCount === 0 ? (
    {projects.length === 0 ? ( diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index e2c9a3490049..0fc18e77a627 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -29,7 +29,7 @@ import { type EnvironmentQueryTarget, } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; -import { useComposerPathSearch, useDebouncedValue } from "~/state/queries"; +import { useComposerPathSearch, useDebouncedValue, useThreadSearch } from "~/state/queries"; import { matchesPullRequestQuery, rankPullRequestMatches, @@ -92,6 +92,8 @@ export function useComposerMenuItems({ pullRequestTextQuery === debouncedPullRequestTextQuery ? pullRequestTextQuery : null; const isPathTrigger = composerTriggerKind === "path"; const environmentThreadShells = useThreadShells(isPathTrigger); + // Mounts older settled threads matching the mention, which shells may leave out. + useThreadSearch(isPathTrigger ? [environmentId] : [], pathTriggerQuery, { titlesOnly: true }); const workspaceEntries = useComposerPathSearch({ environmentId, cwd: isPathTrigger ? gitCwd : null, diff --git a/apps/web/src/components/promptLinks/NewThreadLinkView.tsx b/apps/web/src/components/promptLinks/NewThreadLinkView.tsx index 09f523be1b3b..16f1932dd155 100644 --- a/apps/web/src/components/promptLinks/NewThreadLinkView.tsx +++ b/apps/web/src/components/promptLinks/NewThreadLinkView.tsx @@ -1,8 +1,9 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { getCloneDestinationPath } from "@t3tools/client-runtime/operations/projects"; import { - findPromptLinkProject, + findPromptLinkProjects, findPromptLinkPullRequestThread, + pickPromptLinkPullRequestThread, resolvePromptLinkModelSelection, } from "@t3tools/client-runtime/prompt-links"; import { @@ -10,6 +11,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { type NewThreadLink, type PromptLinkRepository, @@ -17,6 +19,7 @@ import { } from "@t3tools/shared/promptLinks"; import { useAtomValue } from "@effect/atom-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; +import { Atom } from "effect/reactivity"; import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; import { useComposerDraftStore } from "~/composerDraftStore"; @@ -28,8 +31,9 @@ import { useProjects, useThreadShells, waitForProject } from "~/state/entities"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { environmentPresentations } from "~/state/presentation"; -import { allReachableShellsLiveAtom } from "~/state/shell"; +import { allReachableShellsLiveAtom, environmentShell } from "~/state/shell"; import { sourceControlEnvironment } from "~/state/sourceControl"; +import { pullRequestEnvironment } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; import { usePullRequestHandoffs } from "../pullRequest/usePullRequestActions"; @@ -52,13 +56,46 @@ type LinkState = }; type LinkLaunch = - | { readonly kind: "thread"; readonly thread: ReturnType[number] } + | { + readonly kind: "thread"; + readonly thread: Pick[number], "environmentId" | "id">; + } | { readonly kind: "draft"; readonly project: EnvironmentProject } | { readonly kind: "scratch" } | { readonly kind: "settled" }; const openedLinkKeys = new Set(); +type PullRequestThreadLookup = { + readonly targets: ReadonlyArray; + readonly repository: string; + readonly number: number; +}; + +// A pull request's thread can be an older settled thread no shell holds, so ask every +// environment with the repository. +const pullRequestThreadLookupAtom = Atom.family((key: string) => { + const { targets, repository, number } = JSON.parse(key) as PullRequestThreadLookup; + return Atom.make((get) => + pickPromptLinkPullRequestThread( + targets + // A shell that is not live cannot answer, and its lookup would wait forever. + .filter(([environmentId]) => get(environmentShell.statusAtom(environmentId)) === "live") + .map(([environmentId, projectId]) => ({ + environmentId, + result: get( + pullRequestEnvironment.linkedThreadsUncached({ + environmentId, + input: { projectId, repository, number }, + }), + ), + })), + ), + ).pipe(Atom.withLabel(`web-prompt-link-pull-request-thread:${key}`)); +}); + +const NO_PULL_REQUEST_THREAD_LOOKUP = Atom.make(null); + export function NewThreadLinkView({ link }: { readonly link: NewThreadLink }) { const locationKey = useLocation({ select: (location) => location.state.__TSR_key ?? "" }); const navigate = useNavigate(); @@ -218,15 +255,37 @@ export function NewThreadLinkView({ link }: { readonly link: NewThreadLink }) { await openDraft(project); }; - if (ready && state.status === "opening" && !started) { + const localPullRequestThread = + ready && link.kind === "pull-request" + ? findPromptLinkPullRequestThread(threads, link.pullRequest) + : undefined; + // Mounted only while deciding, so "Open it again" asks the server again. + const lookupProjects = + ready && + state.status === "opening" && + !started && + link.kind === "pull-request" && + localPullRequestThread === undefined + ? findPromptLinkProjects(recentProjects(), link.pullRequest) + : []; + const serverPullRequestThread = useAtomValue( + link.kind === "pull-request" && lookupProjects.length > 0 + ? pullRequestThreadLookupAtom( + JSON.stringify({ + targets: lookupProjects.map((project) => [project.environmentId, project.id]), + repository: `${link.pullRequest.owner}/${link.pullRequest.name}`, + number: link.pullRequest.number, + } satisfies PullRequestThreadLookup), + ) + : NO_PULL_REQUEST_THREAD_LOOKUP, + ); + + if (ready && serverPullRequestThread !== undefined && state.status === "opening" && !started) { setStarted(true); - const pullRequestThread = - link.kind === "pull-request" - ? findPromptLinkPullRequestThread(threads, link.pullRequest) - : undefined; + const pullRequestThread = localPullRequestThread ?? serverPullRequestThread ?? undefined; const repository = link.kind === "new" ? link.repository : link.pullRequest; const project = - repository === null ? undefined : findPromptLinkProject(recentProjects(), repository); + repository === null ? undefined : findPromptLinkProjects(recentProjects(), repository)[0]; if (pullRequestThread !== undefined) { setState({ status: "opened" }); setLaunch({ kind: "thread", thread: pullRequestThread }); diff --git a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx index 9f6048e4300c..8750a35660ae 100644 --- a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx +++ b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx @@ -12,6 +12,7 @@ import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pu import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; +import { useThreadSearch } from "~/state/queries"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { openCommandPalette } from "~/commandPaletteBus"; import { Button } from "../ui/button"; @@ -191,6 +192,8 @@ function ThreadPicker({ const linking = usePullRequestLinking(environmentId); const projects = useProjects(); const [query, setQuery] = useState(""); + // Mounts older settled threads matching the query, which shells may leave out. + useThreadSearch([environmentId], query, { titlesOnly: true }); const projectNames = new Map( projects .filter((project) => project.environmentId === environmentId) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index d3b36e0b1b4c..8d2d04f8ed7c 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -48,6 +48,7 @@ import { ProjectActionsSettings } from "./ProjectActionsSettings"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; +import { readUnloadedThreadCount } from "../../state/olderSettledShelf"; import { settingsScopeEnvironmentLabels } from "./settingsScopeAxis"; const ProjectIconPickerDialog = lazy(() => @@ -321,6 +322,7 @@ function ProjectDetail({ hasOtherMembers, displayName: group.displayName, threads, + unloadedThreadCount: readUnloadedThreadCount(members, threads), }); const confirmed = await settlePromise(() => api.dialogs.confirm(confirmationMessage, { variant: "destructive" }), diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 678d72b038da..3a30af7e7138 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -200,6 +200,7 @@ vi.mock("../state/entities", () => { useThread: () => null, }; }); +vi.mock("../state/olderSettledShelf", () => ({ readUnloadedThreadCount: () => 0 })); vi.mock("../state/presentation", () => ({ environmentSummaries: { connectedEnvironmentIdsAtom: "connected-environment-ids" }, })); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 5d1cef34b094..c89b4b73a4d7 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -42,6 +42,7 @@ import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; import { useThreadRouteTarget } from "./useThreadRouteTarget"; +import { readUnloadedThreadCount } from "../state/olderSettledShelf"; interface NewThreadWorkspaceOptions { branch?: string | null; @@ -159,11 +160,16 @@ export function useNewThreadHandler() { ), workspaceRoot: project?.workspaceRoot ?? null, newProjectsRoot: environmentServerConfigs.get(projectRef.environmentId)?.newProjectsRoot, - projectHasThreads: readThreadShells().some( - (thread) => - thread.environmentId === projectRef.environmentId && - thread.projectId === projectRef.projectId, - ), + projectHasThreads: + readThreadShells().some( + (thread) => + thread.environmentId === projectRef.environmentId && + thread.projectId === projectRef.projectId, + ) || + readUnloadedThreadCount( + [{ environmentId: projectRef.environmentId, id: projectRef.projectId }], + [], + ) > 0, }); }; const logicalProjectKey = project diff --git a/apps/web/src/state/olderSettledShelf.ts b/apps/web/src/state/olderSettledShelf.ts new file mode 100644 index 000000000000..ccbbe23f4769 --- /dev/null +++ b/apps/web/src/state/olderSettledShelf.ts @@ -0,0 +1,120 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + isListedSettledThread, + settledShelfFrontier, + unloadedOlderSettledCount, + wantedOlderSettledPages, + type OlderSettledScope, +} from "@t3tools/client-runtime/state/threads"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { useEffect, useState } from "react"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { olderSettledThreads, threadFamilies } from "./threads"; + +const NO_PAGES: ReadonlyMap = new Map(); + +/** + * The Settled shelf for one scope when shells leave older settled threads out: which rows it may + * list, how many threads it holds in all, and the pages read below each environment's cut, whose + * rows merge into the thread list. `useOlderSettledShelfGrowth` reads the next pages. + */ +export function useOlderSettledShelf(input: { + readonly scopes: ReadonlyArray; + /** A collapsed shelf mounts no pages, so a reconnect reads none; expanding restores them. */ + readonly expanded: boolean; + /** Rows the shelf shows; pages are read until it lists that many. */ + readonly visibleCount: number; +}) { + const scopeKey = JSON.stringify(input.scopes); + const [requested, setRequested] = useState({ scopeKey, pages: NO_PAGES }); + const pages = requested.scopeKey === scopeKey && input.expanded ? requested.pages : NO_PAGES; + const shelfScopes = input.scopes.map((scope) => ({ + scope, + pages: pages.get(scope.environmentId) ?? 0, + })); + const shelfKey = JSON.stringify(shelfScopes); + const shelf = useAtomValue(olderSettledThreads.shelfAtom(shelfScopes)); + useEffect(() => { + const releases = (JSON.parse(shelfKey) as typeof shelfScopes).flatMap(({ scope, pages }) => + pages === 0 + ? [] + : [ + threadFamilies.mountRows( + appAtomRegistry, + scope.environmentId, + olderSettledThreads.rowsKey(scope, pages), + olderSettledThreads.pageRowsAtom(scope, pages), + ), + ], + ); + return () => { + for (const release of releases) release(); + }; + }, [shelfKey]); + + const olderOf = (environmentId: EnvironmentId) => shelf.get(environmentId)?.older ?? null; + const pagesOf = (environmentId: EnvironmentId) => shelf.get(environmentId)?.pages; + const frontier = settledShelfFrontier([...shelf.values()]); + const loaded = shelfScopes.filter(({ pages }) => pages > 0); + return { + environments: shelf, + visibleCount: input.expanded ? input.visibleCount : 0, + pages, + grow: (next: ReadonlyMap) => setRequested({ scopeKey, pages: next }), + isListed: (thread: EnvironmentThreadShell) => isListedSettledThread(thread.source, frontier), + /** Older settled threads in scope that are not local; add it to the local Settled rows. */ + unloadedCount: (threads: ReadonlyArray) => + unloadedOlderSettledCount({ olderSettled: olderOf, scopes: input.scopes, threads }), + loading: loaded.some(({ scope }) => pagesOf(scope.environmentId)?.status === "pending"), + failed: loaded.some(({ scope }) => pagesOf(scope.environmentId)?.status === "failed"), + retry: () => { + for (const { scope, pages } of loaded) { + olderSettledThreads.retryPages(appAtomRegistry, scope, pages); + } + }, + }; +} + +/** Reads the next pages while the shelf lists fewer rows than it shows. */ +export function useOlderSettledShelfGrowth( + shelf: ReturnType, + listedSettledThreads: ReadonlyArray, +) { + const wanted = wantedOlderSettledPages({ + requested: shelf.pages, + environments: shelf.environments, + listedCount: listedSettledThreads.length, + visibleCount: shelf.visibleCount, + }); + const { grow, pages } = shelf; + useEffect(() => { + if (wanted !== pages) grow(wanted); + }, [wanted, pages, grow]); +} + +/** + * Threads of these projects that this client does not hold (older settled threads), for counts + * shown outside the shelf, such as a removal confirm. + */ +export function readUnloadedThreadCount( + members: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly id: ProjectId }>, + threads: ReadonlyArray, +): number { + return unloadedOlderSettledCount({ + olderSettled: (environmentId) => + appAtomRegistry.get(olderSettledThreads.olderSettledAtom(environmentId)), + scopes: [...new Set(members.map((member) => member.environmentId))].map((environmentId) => ({ + environmentId, + projectIds: members.flatMap((member) => + member.environmentId === environmentId ? [member.id] : [], + ), + })), + threads: threads.filter((thread) => + members.some( + (member) => member.environmentId === thread.environmentId && member.id === thread.projectId, + ), + ), + }); +} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index f8611ca00f0b..4232b7e82bff 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -9,6 +9,8 @@ import { visibleThreadSearch, type EnvironmentThreadSearchMatch, type SettledThreadSearch, + type ThreadSearchResultsState, + type ThreadSearchScope, } from "@t3tools/client-runtime/state/thread-search"; import { THREAD_SEARCH_QUERY_MAX_LENGTH } from "@t3tools/contracts"; import { messageSearchTokens } from "@t3tools/shared/paletteSearch"; @@ -32,6 +34,7 @@ import { useEffect, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { useFilesystemReadAccess } from "./filesystem"; import { orchestrationEnvironment } from "./orchestration"; +import { olderSettledThreads, threadFamilies } from "./threads"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; import { projectContentSearch, projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; @@ -48,17 +51,31 @@ const EMPTY_PROJECT_ENTRIES: ReadonlyArray = []; const EMPTY_CONTENT_MATCHES: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); -const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ +const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ matches: EMPTY_THREAD_SEARCH_MATCHES, isLoading: false, + rows: [], }).pipe(Atom.withLabel("web:thread-search:empty")); -const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ - getSearchAtom: (environmentId, query) => - orchestrationEnvironment.threadSearch({ - environmentId, - input: { query }, - }), +const olderSettledBeforeAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => get(olderSettledThreads.olderSettledAtom(environmentId))?.before ?? null).pipe( + Atom.withLabel(`web:older-settled-before:${environmentId}`), + ), +); +// Whether any of these environments leaves older settled threads out, so short queries matter. +const anyOlderSettledAtom = Atom.family((environmentIdsKey: string) => + Atom.make((get) => + (JSON.parse(environmentIdsKey) as ReadonlyArray).some( + (environmentId) => get(olderSettledBeforeAtom(environmentId)) !== null, + ), + ).pipe(Atom.withLabel(`web:any-older-settled:${environmentIdsKey}`)), +); +const threadSearch = createThreadSearchResultsAtomFamily({ + getSearchAtom: (environmentId, input) => + input.olderSettledBefore === undefined + ? orchestrationEnvironment.threadSearch({ environmentId, input }) + : olderSettledThreads.searchAtom(environmentId, input), + olderSettledBefore: olderSettledBeforeAtom, labelPrefix: "web:thread-search", }); @@ -77,9 +94,15 @@ export function useDebouncedValue(value: A, delayMs: number): A { return debounced; } +/** + * Server matches for a thread search: message hits, and on environments that leave older settled + * threads out of the shell, the rows of the best older matches, mounted into the thread list while + * they show so the caller's own ranking finds them. + */ export function useThreadSearch( environmentIds: ReadonlyArray, query: string, + scope: ThreadSearchScope = {}, ): { readonly matches: ReadonlyArray; readonly query: string; @@ -88,20 +111,25 @@ export function useThreadSearch( // A pasted wall of text searches by its start rather than failing the request. const normalizedQuery = query.trim().slice(0, THREAD_SEARCH_QUERY_MAX_LENGTH).trim(); const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS); - // The server needs a word of three or more letters; shorter queries would always come back empty. + const anyOlderSettled = useAtomValue(anyOlderSettledAtom(JSON.stringify(environmentIds))); + // Messages need a word of three or more letters; older settled titles match from two. const canSearch = - environmentIds.length > 0 && messageSearchTokens(normalizedQuery).long.length > 0; + environmentIds.length > 0 && + ((scope.titlesOnly !== true && messageSearchTokens(normalizedQuery).long.length > 0) || + (anyOlderSettled && normalizedQuery.length >= 2)); const settledQuery = canSearch && normalizedQuery === debouncedQuery ? debouncedQuery : null; - const searchKey = - settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery); + const searchKeyOf = (searchQuery: string) => + makeThreadSearchKey(environmentIds, searchQuery, scope); + const searchKey = settledQuery === null ? null : searchKeyOf(settledQuery); const result = useAtomValue( - searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey), + searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearch.results(searchKey), ); const isPending = canSearch && (normalizedQuery !== debouncedQuery || result.isLoading); // What fast environments returned shows without waiting for a slow one; // callers keep shown rows in place with `nextShownOrder`. const ready = - settledQuery !== null && (!result.isLoading || result.matches.length > 0) + settledQuery !== null && + (!result.isLoading || result.matches.length > 0 || result.rows.length > 0) ? { query: settledQuery, matches: result.matches } : null; // Kept from earlier renders: React's pattern for information from previous renders. @@ -110,6 +138,23 @@ export function useThreadSearch( const visible = canSearch ? visibleThreadSearch(normalizedQuery, ready, previous) : { query: "", matches: EMPTY_THREAD_SEARCH_MATCHES }; + // The shown results' rows stay mounted until newer results replace them. + const shownKey = visible.query === "" ? null : searchKeyOf(visible.query); + useEffect(() => { + if (shownKey === null) return; + const environments = (JSON.parse(shownKey) as [ReadonlyArray])[0]; + const releases = environments.map((environmentId) => + threadFamilies.mountRows( + appAtomRegistry, + environmentId, + `search:${shownKey}`, + threadSearch.rows(shownKey, environmentId), + ), + ); + return () => { + for (const release of releases) release(); + }; + }, [shownKey]); return { matches: visible.matches, query: visible.query, diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index 656f530ab2d9..9d1a154255e6 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -7,6 +7,7 @@ import { createEnvironmentThreadStateAtoms, EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState, + createOlderSettledThreadAtoms, createThreadEnvironmentAtoms, createThreadFamilyAtoms, } from "@t3tools/client-runtime/state/threads"; @@ -20,7 +21,14 @@ import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentShell } from "./shell"; -const threadFamilies = createThreadFamilyAtoms(connectionAtomRuntime, environmentShell.stateAtom); +export const threadFamilies = createThreadFamilyAtoms( + connectionAtomRuntime, + environmentShell.stateAtom, +); +export const olderSettledThreads = createOlderSettledThreadAtoms( + connectionAtomRuntime, + environmentShell.stateAtom, +); export const threadEnvironment = createThreadEnvironmentAtoms( connectionAtomRuntime, threadFamilies.snapshotAtom, diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md index 8bd6a950d96d..1f3df3ba0aff 100644 --- a/docs/internals/connection-runtime.md +++ b/docs/internals/connection-runtime.md @@ -59,14 +59,34 @@ connection nor overwrite newer live data during a reconnect. Loading and resuming snapshots belongs to the shared state services, so every view agrees on which data is current. -Servers that advertise `shellThreadFamilies` leave idle subagent children out of -shell snapshots when the client asks. A child stays while it has a live or -queued run, a pending request or background work, or a subagent child of its -own, so badge and working walks never meet a gap. Other child rows come from the -[thread family](../../packages/client-runtime/src/state/threadFamilies.ts) that -a thread view or a queued message mounts. A missing shell row therefore does not -prove a thread is gone: code that deletes data or a worktree on absence must -check the family or the server first. +Shell snapshots can leave rows out when the client asks. Servers that advertise +`shellThreadFamilies` leave out idle subagent children: a child stays while it +has a live or queued run, a pending request or background work, or a subagent +child of its own, so badge and working walks never meet a gap. Servers that also +advertise `shellOlderSettled` keep only the 25 newest settled top-level threads +(and any tied with the 25th, pinned, snoozed, live, or above a kept child); the +snapshot's `olderSettled` names that cut and counts the rest per project. Left-out +rows load on demand and merge behind shell rows +([thread families](../../packages/client-runtime/src/state/threadFamilies.ts)): +the family a thread view or a queued message mounts holds every active thread +sharing the thread's root, so it also brings back an old settled thread opened by +link; the Settled shelf reads pages below the cut; searches return the rows of +older matches. A missing shell row therefore does not prove a thread is gone: +code that deletes data or a worktree on absence must check the family or the +server first. + +The Settled count is local Settled rows, minus local rows below the cut, plus the +server's counts +([older settled threads](../../packages/client-runtime/src/state/olderSettledThreads.ts)). +The counts are re-read when a removal or an old row's arrival or change could +move them, so archiving any thread costs one small read. A shelf lists an older +row only above the highest page frontier of the environments that can still +load, so the merged list stays a correct prefix while a page loads. An +environment with no snapshot this session, or a failed page, holds no one back. +On-demand reads wait for the session's first authoritative snapshot +(`generation > 0`), and each is keyed by generation. A resume that skips that +snapshot must still bump `generation` once verified, and may reuse a cached +snapshot only when its omissions match what the session asks for. [Thread detail](../../packages/client-runtime/src/state/threads.ts) separates subscription lifetime from cache lifetime. Mounted consumers share one live diff --git a/packages/client-runtime/src/promptLinks.test.ts b/packages/client-runtime/src/promptLinks.test.ts index 7826f9aab5ce..213392f6c61f 100644 --- a/packages/client-runtime/src/promptLinks.test.ts +++ b/packages/client-runtime/src/promptLinks.test.ts @@ -1,11 +1,20 @@ import { + EnvironmentId, + ProjectId, ProviderDriverKind, ProviderInstanceId, + ThreadId, + type PullRequestLinkedThreadsResult, type RepositoryIdentity, } from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; import { describe, expect, it } from "vite-plus/test"; -import { findPromptLinkProject, resolvePromptLinkModelSelection } from "./promptLinks.ts"; +import { + findPromptLinkProjects, + pickPromptLinkPullRequestThread, + resolvePromptLinkModelSelection, +} from "./promptLinks.ts"; const identity = (canonicalKey: string, origin?: string): RepositoryIdentity => ({ canonicalKey, @@ -13,8 +22,8 @@ const identity = (canonicalKey: string, origin?: string): RepositoryIdentity => ...(origin ? { origin: { canonicalKey: origin } } : {}), }); -describe("findPromptLinkProject", () => { - it("takes the first exact match and never a fork of the repository", () => { +describe("findPromptLinkProjects", () => { + it("keeps exact matches in order and never a fork of the repository", () => { const projects = [ { id: "fork", @@ -24,11 +33,50 @@ describe("findPromptLinkProject", () => { { id: "recent", repositoryIdentity: identity("github.com/PingDotGG/T3Code") }, { id: "older", repositoryIdentity: identity("github.com/pingdotgg/t3code") }, ]; - expect(findPromptLinkProject(projects, { owner: "pingdotgg", name: "t3code" })?.id).toBe( - "recent", + const ids = (owner: string, name: string) => + findPromptLinkProjects(projects, { owner, name }).map((project) => project.id); + expect(ids("pingdotgg", "t3code")).toEqual(["recent", "older"]); + expect(ids("me", "t3code")).toEqual(["fork"]); + expect(ids("gitlab", "x")).toEqual([]); + }); +}); + +describe("pickPromptLinkPullRequestThread", () => { + it("takes the first open thread in order once every earlier lookup has answered", () => { + const laptop = EnvironmentId.make("laptop"); + const server = EnvironmentId.make("server"); + const linked = ( + id: string, + archivedAt: string | null = null, + ): PullRequestLinkedThreadsResult => ({ + threads: [{ id: ThreadId.make(id), projectId: ProjectId.make("p"), title: id, archivedAt }], + }); + const none: PullRequestLinkedThreadsResult = { threads: [] }; + const pick = ( + ...results: ReadonlyArray> + ) => + pickPromptLinkPullRequestThread( + results.map((result, index) => ({ environmentId: index === 0 ? laptop : server, result })), + ); + // A value shown while it refetches is not an answer yet. + expect(pick(AsyncResult.success(none, { waiting: true }))).toBeUndefined(); + expect(pick(AsyncResult.success(none), AsyncResult.initial(true))).toBeUndefined(); + expect(pick(AsyncResult.initial(true), AsyncResult.success(linked("b")))).toBeUndefined(); + const laptopThread = { environmentId: laptop, id: ThreadId.make("a") }; + expect(pick(AsyncResult.success(linked("a")), AsyncResult.initial(true))).toEqual(laptopThread); + expect(pick(AsyncResult.success(linked("a")), AsyncResult.success(linked("b")))).toEqual( + laptopThread, ); - expect(findPromptLinkProject(projects, { owner: "me", name: "t3code" })?.id).toBe("fork"); - expect(findPromptLinkProject(projects, { owner: "gitlab", name: "x" })).toBeUndefined(); + expect(pick(AsyncResult.success(none), AsyncResult.success(linked("old")))).toEqual({ + environmentId: server, + id: ThreadId.make("old"), + }); + expect( + pick( + AsyncResult.success(linked("archived", "2026-01-01T00:00:00.000Z")), + AsyncResult.fail(1), + ), + ).toBeNull(); }); }); diff --git a/packages/client-runtime/src/promptLinks.ts b/packages/client-runtime/src/promptLinks.ts index 175b3aa5cb6f..135b1c13f110 100644 --- a/packages/client-runtime/src/promptLinks.ts +++ b/packages/client-runtime/src/promptLinks.ts @@ -1,12 +1,16 @@ import { repositoryGroupingKeyOf, + type EnvironmentId, type ModelSelection, + type PullRequestLinkedThreadsResult, type RepositoryIdentity, type ServerProvider, type ThreadLinkedPullRequest, + type ThreadId, type ThreadPullRequestLink, } from "@t3tools/contracts"; import { resolveSelectableModel, type SelectableModelOption } from "@t3tools/shared/model"; +import type { AsyncResult } from "effect/reactivity"; import type { PromptLinkPullRequest, PromptLinkRepository } from "@t3tools/shared/promptLinks"; import { threadPullRequestKeyOf, @@ -18,13 +22,14 @@ const repositoryKey = (repository: PromptLinkRepository) => `github.com/${repository.owner}/${repository.name}`.toLowerCase(); /** - * Callers pass projects most recently active first, so the user lands where they last worked. + * The repository's projects in the order given. Callers pass projects most recently active + * first and open the first, so the user lands where they last worked. */ -export function findPromptLinkProject< +export function findPromptLinkProjects< P extends { readonly repositoryIdentity?: RepositoryIdentity | null | undefined }, ->(projects: ReadonlyArray

    , repository: PromptLinkRepository): P | undefined { +>(projects: ReadonlyArray

    , repository: PromptLinkRepository): P[] { const key = repositoryKey(repository); - return projects.find( + return projects.filter( (project) => project.repositoryIdentity != null && repositoryGroupingKeyOf(project.repositoryIdentity).toLowerCase() === key, @@ -54,6 +59,27 @@ export function findPromptLinkPullRequestThread< return latest; } +/** + * The first open thread from per-environment pull request lookups, in the order given, once + * every lookup before it has answered. `null` when none has one; `undefined` until then. + */ +export function pickPromptLinkPullRequestThread( + lookups: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly result: AsyncResult.AsyncResult; + }>, +): { readonly environmentId: EnvironmentId; readonly id: ThreadId } | null | undefined { + for (const { environmentId, result } of lookups) { + if (result._tag === "Initial" || result.waiting) return undefined; + const thread = + result._tag === "Success" + ? result.value.threads.find((candidate) => candidate.archivedAt === null) + : undefined; + if (thread !== undefined) return { environmentId, id: thread.id }; + } + return null; +} + const PROVIDER_ALIASES: Record = { claude: "claudeagent" }; /** diff --git a/packages/client-runtime/src/state/olderSettledThreads.test.ts b/packages/client-runtime/src/state/olderSettledThreads.test.ts new file mode 100644 index 000000000000..e8c419f779d2 --- /dev/null +++ b/packages/client-runtime/src/state/olderSettledThreads.test.ts @@ -0,0 +1,566 @@ +import { + EnvironmentId, + ORCHESTRATION_V2_WS_METHODS, + OrchestrationV2GetShellSnapshotError, + ProjectId, + ThreadId, + type OrchestrationV2OlderSettledThreadsPage, + type OrchestrationV2SearchThreadsResult, + type OrchestrationV2ShellSnapshot, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import * as TestClock from "effect/testing/TestClock"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type * as RpcSession from "../rpc/session.ts"; +import { EMPTY_SHELL_STATE, type EnvironmentShellState } from "./shell.ts"; +import { + createOlderSettledThreadAtoms, + isListedSettledThread, + olderSettledCountKey, + settledShelfFrontier, + unloadedOlderSettledCount, + wantedOlderSettledPages, + type OlderSettled, + type OlderSettledPages, +} from "./olderSettledThreads.ts"; + +const DAY = 24 * 60 * 60 * 1000; +const environmentId = EnvironmentId.make("env-a"); +const project = ProjectId.make("project-a"); +const before = DateTime.formatIso(DateTime.makeUnsafe(50 * DAY)); +const older: OlderSettled = { + before, + beforeMs: 50 * DAY, + counts: new Map([[project, 3]]), +}; + +const row = ( + id: string, + input: { + readonly settledDay?: number; + readonly createdDay?: number; + readonly subagent?: boolean; + }, +) => + ({ + id: ThreadId.make(id), + projectId: project, + lineage: { + parentThreadId: input.subagent === true ? ThreadId.make("parent") : null, + relationshipToParent: input.subagent === true ? "subagent" : null, + rootThreadId: ThreadId.make(id), + }, + settledOverride: input.settledDay === undefined ? null : "settled", + settledAt: input.settledDay === undefined ? null : DateTime.makeUnsafe(input.settledDay * DAY), + createdAt: DateTime.makeUnsafe((input.createdDay ?? 1) * DAY), + }) as unknown as OrchestrationV2ThreadShell; + +describe("olderSettledCountKey", () => { + const key = ( + threads: ReadonlyArray, + removedAt: ReadonlyMap = new Map(), + ) => olderSettledCountKey(threads, older.beforeMs, removedAt); + const base = [row("kept-old", { settledDay: 10 }), row("open", { createdDay: 60 })]; + + it("changes on every event that can change the older count", () => { + const start = key(base); + // A removal, even of an id removed before (the map keeps its size). + expect(key(base, new Map([[ThreadId.make("x"), 4]]))).not.toBe(start); + expect(key(base, new Map([[ThreadId.make("x"), 9]]))).not.toBe( + key(base, new Map([[ThreadId.make("x"), 4]])), + ); + // An omitted thread that arrives unsettled. + expect(key([...base, row("woken", { createdDay: 5 })])).not.toBe(start); + // A local row that turns older (a backdated auto-settle). + expect(key([base[0]!, row("open", { createdDay: 60, settledDay: 20 })])).not.toBe(start); + }); + + it("stays put for new threads and subagents", () => { + const start = key(base); + expect(key([...base, row("new", { createdDay: 70 })])).toBe(start); + expect(key([...base, row("new-settled", { createdDay: 70, settledDay: 71 })])).toBe(start); + expect(key([...base, row("child", { createdDay: 5, subagent: true })])).toBe(start); + }); +}); + +describe("settled shelf listing", () => { + const pages = (frontier: OlderSettledPages["frontier"]): OlderSettledPages => ({ + reads: [], + frontier, + status: "ready", + }); + const at = (day: number) => DateTime.formatIso(DateTime.makeUnsafe(day * DAY)); + + it("lists rows at or above the highest frontier still loading", () => { + // No page yet: the cut is the frontier, so only rows newer than it list. + const one = settledShelfFrontier([{ older, pages: pages({ settledAt: before }) }]); + expect(isListedSettledThread(row("recent", { settledDay: 60 }), one)).toBe(true); + expect(isListedSettledThread(row("old", { settledDay: 30 }), one)).toBe(false); + // A row that became older inside the loaded range lists at its place. + const loaded = settledShelfFrontier([{ older, pages: pages({ settledAt: at(20) }) }]); + expect(isListedSettledThread(row("old", { settledDay: 30 }), loaded)).toBe(true); + expect(isListedSettledThread(row("older", { settledDay: 10 }), loaded)).toBe(false); + // All loaded, or a shell that keeps every settled thread: everything lists. + expect(settledShelfFrontier([{ older, pages: pages(null) }])).toBeNull(); + expect(settledShelfFrontier([{ older: null, pages: pages(null) }])).toBeNull(); + }); + + it("holds back another environment's rows below an environment still loading", () => { + const frontier = settledShelfFrontier([ + { older, pages: pages({ settledAt: at(40) }) }, + { older, pages: pages({ settledAt: at(20) }) }, + ]); + expect(frontier).toEqual({ settledAt: at(40) }); + expect(isListedSettledThread(row("b-loaded", { settledDay: 30 }), frontier)).toBe(false); + // A failed environment holds no one back. + expect( + settledShelfFrontier([ + { older, pages: { ...pages({ settledAt: at(40) }), status: "failed" } }, + { older, pages: pages({ settledAt: at(20) }) }, + ]), + ).toEqual({ settledAt: at(20) }); + }); + + it("on a settle-time tie, stops at the cursor that admits fewest rows", () => { + const strict = pages({ settledAt: at(30) }); + const afterM = pages({ settledAt: at(30), threadId: ThreadId.make("m") }); + const afterK = pages({ settledAt: at(30), threadId: ThreadId.make("k") }); + const frontier = (...all: ReadonlyArray) => + settledShelfFrontier(all.map((each) => ({ older, pages: each }))); + expect(frontier(strict, afterM)).toEqual(afterM.frontier); + expect(frontier(afterM, strict)).toEqual(afterM.frontier); + expect(frontier(afterM, afterK)).toEqual(afterK.frontier); + }); + + it("follows the server's id order on a settle-time tie", () => { + const frontier = { settledAt: at(30), threadId: ThreadId.make("m") }; + expect(isListedSettledThread(row("a", { settledDay: 30 }), frontier)).toBe(true); + expect(isListedSettledThread(row("z", { settledDay: 30 }), frontier)).toBe(false); + }); +}); + +it("counts older threads the client does not hold, once", () => { + const local = [ + // Kept by the snapshot (snoozed) and loaded by a page: the server counts both. + { source: row("snoozed-old", { settledDay: 10 }), environmentId }, + { source: row("paged-old", { settledDay: 20 }), environmentId }, + { source: row("recent", { settledDay: 60 }), environmentId }, + ]; + const olderSettled = () => older; + expect( + unloadedOlderSettledCount({ + olderSettled, + scopes: [{ environmentId, projectIds: null }], + threads: local, + }), + ).toBe(1); + expect( + unloadedOlderSettledCount({ + olderSettled, + scopes: [{ environmentId, projectIds: [ProjectId.make("other")] }], + threads: local, + }), + ).toBe(0); + // Two projects in one environment: each subtracts only its own loaded rows. + const other = ProjectId.make("project-b"); + const twoProjects = (): OlderSettled => ({ + ...older, + counts: new Map([ + [project, 10], + [other, 10], + ]), + }); + expect( + unloadedOlderSettledCount({ + olderSettled: twoProjects, + scopes: [{ environmentId, projectIds: [project, other] }], + threads: [ + { source: row("a-old", { settledDay: 10 }), environmentId }, + { source: { ...row("b-old", { settledDay: 10 }), projectId: other }, environmentId }, + ], + }), + ).toBe(18); +}); + +describe("wantedOlderSettledPages", () => { + const otherEnvironment = EnvironmentId.make("env-b"); + const full = (settledAt: string): OlderSettledPages => ({ + reads: [], + frontier: { settledAt, threadId: ThreadId.make("t") }, + status: "ready", + }); + const wanted = (input: { + readonly requested?: ReadonlyMap; + readonly environments: ReadonlyMap; + readonly listed: number; + readonly visible: number; + }) => + wantedOlderSettledPages({ + requested: input.requested ?? new Map(), + environments: new Map( + [...input.environments].map(([id, { pages }]) => [id, { older, pages }] as const), + ), + listedCount: input.listed, + visibleCount: input.visible, + }); + + it("reads the next page of the environment holding the shelf back", () => { + const environments = new Map([ + [environmentId, { pages: full("2026-01-10T00:00:00.000Z") }], + [otherEnvironment, { pages: full("2026-01-20T00:00:00.000Z") }], + ]); + expect(wanted({ environments, listed: 9, visible: 10 })).toEqual( + new Map([[otherEnvironment, 1]]), + ); + // A deleted row drops the listed count again. + expect( + wanted({ requested: new Map([[environmentId, 1]]), environments, listed: 34, visible: 35 }), + ).toEqual( + new Map([ + [environmentId, 1], + [otherEnvironment, 1], + ]), + ); + }); + + it("waits for pending pages and stops at a short page or a closed shelf", () => { + const one = (pages: OlderSettledPages) => new Map([[environmentId, { pages }]]); + const pending = { ...full(before), status: "pending" as const }; + const requested = new Map([[environmentId, 1]]); + expect(wanted({ requested, environments: one(pending), listed: 0, visible: 10 })).toBe( + requested, + ); + const done = { ...full(before), frontier: null }; + expect(wanted({ requested, environments: one(done), listed: 3, visible: 10 })).toBe(requested); + expect(wanted({ requested, environments: one(full(before)), listed: 10, visible: 10 })).toBe( + requested, + ); + expect(wanted({ requested, environments: one(full(before)), listed: 0, visible: 0 })).toBe( + requested, + ); + }); +}); + +const TARGET = new PrimaryConnectionTarget({ + environmentId, + label: "Older settled environment", + httpBaseUrl: "https://older.example.test", + wsBaseUrl: "wss://older.example.test", +}); + +// The atoms over a fake connected environment whose `getOlderSettledThreads` and `searchThreads` +// take their replies, in order, from `replies`. +const makeAtoms = Effect.fn(function* () { + const replies = + yield* Queue.unbounded< + Effect.Effect< + OrchestrationV2OlderSettledThreadsPage | OrchestrationV2SearchThreadsResult, + OrchestrationV2GetShellSnapshotError + > + >(); + const requests: unknown[] = []; + // Each request, with a signal for when its read is stopped. + const requestLog = yield* Queue.unbounded<{ + readonly input: unknown; + readonly stopped: Deferred.Deferred; + }>(); + const reply = (input: unknown) => + Effect.gen(function* () { + requests.push(input); + const stopped = yield* Deferred.make(); + yield* Queue.offer(requestLog, { input, stopped }); + return yield* Effect.flatten(Queue.take(replies)).pipe( + Effect.onInterrupt(() => Deferred.succeed(stopped, undefined)), + ); + }); + const client = { + [ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads]: reply, + [ORCHESTRATION_V2_WS_METHODS.searchThreads]: reply, + } as unknown as WsRpcProtocolClient; + const session = { + client, + initialConfig: Effect.succeed({} as never), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + } as unknown as RpcSession.RpcSession; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, + }), + session: yield* SubscriptionRef.make(Option.some(session)), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const followStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["followStream"] = ( + _environmentId, + stream, + ) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed( + EnvironmentRegistry.EnvironmentRegistry, + EnvironmentRegistry.EnvironmentRegistry.of({ + run, + followStream, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]), + ), + // The test's clock, so the count re-read's wait moves with `TestClock.adjust`. + Layer.succeed(Clock.Clock, yield* Clock.Clock), + ), + ); + const shellState = Atom.make>( + AsyncResult.success(EMPTY_SHELL_STATE), + ); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + return { + replies, + requests, + requestLog, + atoms: createOlderSettledThreadAtoms(runtime, () => shellState), + registry, + setShell: (state: Partial) => + registry.set(shellState, AsyncResult.success({ ...EMPTY_SHELL_STATE, ...state })), + // The first value that passes, from a subscription. + until: (atom: Atom.Atom, done: (value: A) => boolean) => + AtomRegistry.toStream(registry, atom).pipe( + Stream.filter(done), + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + }; +}); + +const shellSnapshot = (input: { + readonly sequence: number; + readonly asOfSequence: number; + readonly count: number; +}) => + Option.some({ + schemaVersion: 1, + snapshotSequence: input.sequence, + projects: [], + threads: [row("recent", { settledDay: 60 })], + archivedThreads: [], + olderSettled: { + before, + asOfSequence: input.asOfSequence, + counts: [{ projectId: project, count: input.count }], + }, + } as unknown as OrchestrationV2ShellSnapshot); + +describe("createOlderSettledThreadAtoms", () => { + it.effect("keeps counts current: the snapshot's own, then one read after changes settle", () => + Effect.gen(function* () { + const { replies, requests, atoms, registry, setShell, until } = yield* makeAtoms(); + const olderAtom = atoms.olderSettledAtom(environmentId); + const count = (value: OlderSettled | null) => value?.counts.get(project); + registry.mount(olderAtom); + + // A cached shell: its counts stand, read nothing. + setShell({ snapshot: shellSnapshot({ sequence: 5, asOfSequence: 3, count: 5 }) }); + expect(count(registry.get(olderAtom))).toBe(5); + // This session's snapshot: its counts are current until something changes. + setShell({ + snapshot: shellSnapshot({ sequence: 3, asOfSequence: 3, count: 5 }), + generation: 1, + }); + expect(count(registry.get(olderAtom))).toBe(5); + + // A removal can change the count: one read once changes stop for a moment. + yield* Queue.offer( + replies, + Effect.succeed({ + snapshotSequence: 4, + threads: [], + counts: [{ projectId: project, count: 4 }], + }), + ); + setShell({ + snapshot: shellSnapshot({ sequence: 4, asOfSequence: 3, count: 5 }), + generation: 1, + removedAt: new Map([[ThreadId.make("gone"), 4]]), + }); + expect(count(registry.get(olderAtom))).toBe(5); + yield* TestClock.adjust("500 millis"); + expect(count(yield* until(olderAtom, (value) => count(value) === 4))).toBe(4); + expect(requests).toEqual([{ before: { settledAt: before }, limit: 0 }]); + + // A new snapshot brings its own counts and drops the read. + setShell({ + snapshot: shellSnapshot({ sequence: 9, asOfSequence: 9, count: 7 }), + generation: 2, + }); + expect(count(registry.get(olderAtom))).toBe(7); + expect(requests).toHaveLength(1); + }), + ); + + it.effect("reads pages in order until a short one, and retries a failed page", () => + Effect.gen(function* () { + const { replies, requests, atoms, registry, setShell, until } = yield* makeAtoms(); + const scope = { environmentId, projectIds: null }; + const snapshot = shellSnapshot({ sequence: 3, asOfSequence: 3, count: 30 }); + + // A cached shell holds no other environment back: nothing can load yet. + setShell({ snapshot }); + expect(registry.get(atoms.pagesAtom(scope, 1))).toEqual({ + reads: [], + frontier: null, + status: "ready", + }); + + setShell({ snapshot, generation: 1 }); + expect(registry.get(atoms.pagesAtom(scope, 0)).frontier).toEqual({ settledAt: before }); + const full = Array.from({ length: 25 }, (_, index) => + row(`full-${String(index).padStart(2, "0")}`, { settledDay: 40 - index }), + ); + yield* Queue.offer( + replies, + Effect.succeed({ snapshotSequence: 3, threads: full, counts: [] }), + ); + yield* Queue.offer( + replies, + Effect.succeed({ + snapshotSequence: 3, + threads: [row("short", { settledDay: 10 })], + counts: [], + }), + ); + const twoPages = atoms.pagesAtom(scope, 2); + registry.mount(twoPages); + const done = yield* until(twoPages, (pages) => pages.reads.length === 2); + expect(done.frontier).toBeNull(); + expect(done.status).toBe("ready"); + // The second page continues from the first page's last row. + expect(requests).toEqual([ + { before: { settledAt: before }, limit: 25 }, + { + before: { settledAt: DateTime.formatIso(full[24]!.settledAt!), threadId: full[24]!.id }, + limit: 25, + }, + ]); + + // A new snapshot reads again; a failed page shows until a retry reads it. + const onePage = atoms.pagesAtom(scope, 1); + registry.mount(onePage); + setShell({ snapshot, generation: 2 }); + expect(registry.get(onePage).status).toBe("pending"); + yield* Queue.offer( + replies, + Effect.fail(new OrchestrationV2GetShellSnapshotError({ message: "read failed" })), + ); + expect((yield* until(onePage, (pages) => pages.status === "failed")).frontier).toEqual({ + settledAt: before, + }); + atoms.retryPages(registry, scope, 1); + expect(registry.get(onePage).status).toBe("pending"); + yield* Queue.offer( + replies, + Effect.succeed({ snapshotSequence: 3, threads: full.slice(0, 3), counts: [] }), + ); + const retried = yield* until(onePage, (pages) => pages.status === "ready"); + expect(retried.reads).toHaveLength(1); + expect(retried.frontier).toBeNull(); + }), + ); + + it.effect("searches read once per generation, and nothing before the first snapshot", () => + Effect.gen(function* () { + const { replies, requests, atoms, registry, setShell, until } = yield* makeAtoms(); + const snapshot = shellSnapshot({ sequence: 3, asOfSequence: 3, count: 30 }); + const input = { query: "old", olderSettledBefore: before, titlesOnly: true }; + const search = atoms.searchAtom(environmentId, input); + + // That snapshot would drop the rows: loading while it is on its way, idle while offline. + setShell({ snapshot, status: "synchronizing" }); + registry.mount(search); + expect(registry.get(search).waiting).toBe(true); + setShell({ snapshot, status: "cached" }); + expect(registry.get(search).waiting).toBe(false); + + const found = { matches: [], snapshotSequence: 3, threads: [row("old", { settledDay: 9 })] }; + setShell({ snapshot, status: "live", generation: 1 }); + yield* Queue.offer(replies, Effect.succeed(found)); + yield* until(search, AsyncResult.isSuccess); + // A new snapshot reads the rows again. + setShell({ snapshot, status: "live", generation: 2 }); + yield* Queue.offer(replies, Effect.succeed({ ...found, snapshotSequence: 8 })); + yield* until( + search, + (result) => Option.getOrNull(AsyncResult.value(result))?.snapshotSequence === 8, + ); + expect(requests).toEqual([input, input]); + }), + ); + + it.effect("growing reads only the next page, and collapsing stops the shelf's reads", () => + Effect.gen(function* () { + const { replies, requestLog, atoms, registry, setShell, until } = yield* makeAtoms(); + setShell({ + snapshot: shellSnapshot({ sequence: 3, asOfSequence: 3, count: 30 }), + generation: 1, + }); + const scope = { environmentId, projectIds: null }; + const full = Array.from({ length: 25 }, (_, index) => + row(`full-${String(index).padStart(2, "0")}`, { settledDay: 40 - index }), + ); + const releaseOne = registry.mount(atoms.pagesAtom(scope, 1)); + expect((yield* Queue.take(requestLog)).input).toEqual({ + before: { settledAt: before }, + limit: 25, + }); + yield* Queue.offer( + replies, + Effect.succeed({ snapshotSequence: 3, threads: full, counts: [] }), + ); + yield* until(atoms.pagesAtom(scope, 1), (pages) => pages.reads.length === 1); + + // React builds the next shelf before the last one lets go, so page 1 is not read again. + registry.get(atoms.pagesAtom(scope, 2)); + releaseOne(); + const releaseTwo = registry.mount(atoms.pagesAtom(scope, 2)); + const second = yield* Queue.take(requestLog); + expect(second.input).toEqual({ + before: { settledAt: DateTime.formatIso(full[24]!.settledAt!), threadId: full[24]!.id }, + limit: 25, + }); + + // Collapsing stops the pending read: nothing is left to run again on a reconnect. + releaseTwo(); + yield* Deferred.await(second.stopped); + }), + ); +}); diff --git a/packages/client-runtime/src/state/olderSettledThreads.ts b/packages/client-runtime/src/state/olderSettledThreads.ts new file mode 100644 index 000000000000..56dcd144c0b7 --- /dev/null +++ b/packages/client-runtime/src/state/olderSettledThreads.ts @@ -0,0 +1,470 @@ +import { + isOlderSettledThreadShell, + ORCHESTRATION_V2_WS_METHODS, + type EnvironmentId, + type OrchestrationV2OlderSettledCursor, + type OrchestrationV2OlderSettledThreadsPage, + type OrchestrationV2SearchThreadsInput, + type OrchestrationV2ThreadFamily, + type OrchestrationV2ThreadShell, + type ProjectId, + type ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; + +import type * as EnvironmentRegistry from "../connection/registry.ts"; +import { request } from "../rpc/client.ts"; +import { createEnvironmentQueryAtomFamily, selectEnvironmentQueryData } from "./runtime.ts"; +import type { EnvironmentShellState } from "./shell.ts"; +import { threadFamilyRead } from "./threadFamilies.ts"; + +// Rows per page of older settled threads: one "Show more" step of the Settled shelf. +const OLDER_SETTLED_PAGE_SIZE = 25; +// A burst of changes (a PR sync touching many old threads) costs one count read. +const COUNT_REREAD_DELAY = "500 millis"; + +/** Where an environment's shell cut settled threads, and how many are older per project. */ +export interface OlderSettled { + readonly before: string; + readonly beforeMs: number; + readonly counts: ReadonlyMap; +} + +/** The scope's projects in one environment, or null for all of them. */ +export interface OlderSettledScope { + readonly environmentId: EnvironmentId; + readonly projectIds: ReadonlyArray | null; +} + +/** + * The pages read below an environment's cut. Everything at or above `frontier` is local: pages + * hold the rows that were there when read, and shell deltas keep that range current. A null + * frontier means no more can load: every older thread is loaded, or this session has no snapshot. + */ +export interface OlderSettledPages { + readonly reads: ReadonlyArray; + readonly frontier: OrchestrationV2OlderSettledCursor | null; + readonly status: "ready" | "pending" | "failed"; +} + +const NO_PAGES: OlderSettledPages = { reads: [], frontier: null, status: "ready" }; + +type OlderSettledRow = Pick< + OrchestrationV2ThreadShell, + "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" +>; + +export interface OlderSettledShelfEnvironment { + readonly older: OlderSettled | null; + readonly pages: OlderSettledPages; +} + +/** + * Changes whenever the older count can: any removal (by largest sequence, so a repeat counts), an + * older row that arrives or changes, or an old row arriving outside the set (unsettled). Threads + * created after the cut never enter it, as threads settle after creation. Archived-thread events + * arrive as removals too, each costing one count read. + */ +export function olderSettledCountKey( + threads: ReadonlyArray, + beforeMs: number, + removedAt: ReadonlyMap, +): string { + let lastRemoval = 0; + for (const sequence of removedAt.values()) lastRemoval = Math.max(lastRemoval, sequence); + const rows: string[] = []; + for (const thread of threads) { + if (thread.lineage.relationshipToParent === "subagent") continue; + const older = isOlderSettledThreadShell(thread, beforeMs); + if (older || DateTime.toEpochMillis(thread.createdAt) < beforeMs) { + rows.push(`${thread.id}:${older ? 1 : 0}`); + } + } + return `${lastRemoval}|${rows.sort().join(",")}`; +} + +/** Whether a row sits at or above a page cursor in the server's order (settle time desc, id asc). */ +function atOrAboveCursor( + thread: Pick, + cursor: OrchestrationV2OlderSettledCursor, +): boolean { + if (thread.settledAt === null) return true; + const settledAt = DateTime.formatIso(thread.settledAt); + return !( + settledAt < cursor.settledAt || + (cursor.threadId !== undefined && settledAt === cursor.settledAt && thread.id > cursor.threadId) + ); +} + +/** + * Where a shelf over these environments stops listing: the highest frontier of any environment + * that can still load older threads (its cut until its first page). Rows below it could sort under + * rows not loaded yet, so the merged list is a correct prefix even while a page loads. A failed + * environment holds no one back; its Retry rejoins it. + */ +export function settledShelfFrontier( + environments: ReadonlyArray, +): OrchestrationV2OlderSettledCursor | null { + let highest: OrchestrationV2OlderSettledCursor | null = null; + for (const { older, pages } of environments) { + if (older === null || pages.frontier === null || pages.status === "failed") continue; + const frontier = pages.frontier; + // On a settle-time tie, a cursor with an id stops above one without, and a smaller id higher. + if ( + highest === null || + frontier.settledAt > highest.settledAt || + (frontier.settledAt === highest.settledAt && + frontier.threadId !== undefined && + (highest.threadId === undefined || frontier.threadId < highest.threadId)) + ) { + highest = frontier; + } + } + return highest; +} + +/** + * Whether the Settled shelf may list a row: at or above the shelf's frontier. So a row that turns + * older mid-session (a backdated auto-settle, a snooze that ends, an unarchive) waits for the + * range that holds it. + */ +export function isListedSettledThread( + thread: Pick, + frontier: OrchestrationV2OlderSettledCursor | null, +): boolean { + return frontier === null || atOrAboveCursor(thread, frontier); +} + +/** + * Older settled threads in scope that this client does not hold: the server's counts minus the + * older rows it has. Add it to the local rows for the Settled label or a project's thread count. + */ +export function unloadedOlderSettledCount(input: { + readonly olderSettled: (environmentId: EnvironmentId) => OlderSettled | null; + readonly scopes: ReadonlyArray; + /** Every row the client holds in scope, in any section. */ + readonly threads: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly source: OlderSettledRow; + }>; +}): number { + let unloaded = 0; + for (const scope of input.scopes) { + const older = input.olderSettled(scope.environmentId); + if (older === null) continue; + if (scope.projectIds === null) { + for (const count of older.counts.values()) unloaded += count; + } else { + for (const projectId of scope.projectIds) unloaded += older.counts.get(projectId) ?? 0; + } + for (const thread of input.threads) { + if ( + thread.environmentId === scope.environmentId && + (scope.projectIds === null || scope.projectIds.includes(thread.source.projectId)) && + isOlderSettledThreadShell(thread.source, older.beforeMs) + ) { + unloaded -= 1; + } + } + } + return Math.max(0, unloaded); +} + +/** + * The pages each environment should read: while the shelf lists fewer than `visibleCount` rows, + * one more for each environment whose frontier is the shelf's, once its pages have loaded. A + * short page ends an environment's chain; counts can lag a backdated settle, so they do not gate. + */ +export function wantedOlderSettledPages(input: { + readonly requested: ReadonlyMap; + readonly environments: ReadonlyMap; + readonly listedCount: number; + readonly visibleCount: number; +}): ReadonlyMap { + if (input.listedCount >= input.visibleCount) return input.requested; + const frontier = settledShelfFrontier([...input.environments.values()]); + if (frontier === null) return input.requested; + let next: Map | null = null; + for (const [environmentId, { older, pages }] of input.environments) { + if (older === null || pages.status !== "ready" || pages.frontier === null) continue; + if ( + pages.frontier.settledAt !== frontier.settledAt || + pages.frontier.threadId !== frontier.threadId + ) { + continue; + } + next ??= new Map(input.requested); + next.set(environmentId, (input.requested.get(environmentId) ?? 0) + 1); + } + return next ?? input.requested; +} + +/** + * Shells that omit older settled threads (`omitOlderSettled`) name the cut and count what they left + * out. These atoms keep those counts current and read older threads in pages for the Settled shelf. + */ +export function createOlderSettledThreadAtoms( + runtime: Atom.AtomRuntime, + shellStateAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom>, +) { + const shellStateValue = (state: AsyncResult.AsyncResult) => + Option.getOrNull(AsyncResult.value(state)); + + const countReadAtom = createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:orchestration:older-settled-counts", + staleTimeMs: Number.POSITIVE_INFINITY, + // A newer key unmounts this read; with no idle time its wait is interrupted before the RPC. + idleTtlMs: 0, + execute: Effect.fn("OlderSettledThreads.readCounts")(function* (input: { + readonly generation: number; + readonly before: string; + readonly key: string; + }) { + yield* Effect.sleep(COUNT_REREAD_DELAY); + return yield* request(ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads, { + before: { settledAt: input.before }, + limit: 0, + }); + }), + }); + + const pageReadAtom = createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:orchestration:older-settled-page", + staleTimeMs: Number.POSITIVE_INFINITY, + // A collapsed shelf keeps no read alive, as an idle one runs again when a reconnect replaces + // the session. + idleTtlMs: 0, + execute: Effect.fn("OlderSettledThreads.readPage")(function* (input: { + readonly generation: number; + readonly projectIds: ReadonlyArray | null; + readonly before: OrchestrationV2OlderSettledCursor; + }) { + return yield* request(ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads, { + before: input.before, + ...(input.projectIds === null ? {} : { projectIds: input.projectIds }), + limit: OLDER_SETTLED_PAGE_SIZE, + }); + }), + }); + + const countsOf = (source: { + readonly counts: OrchestrationV2OlderSettledThreadsPage["counts"]; + }) => new Map(source.counts.map(({ projectId, count }) => [projectId, count] as const)); + + const olderSettledAtom = Atom.family((environmentId: EnvironmentId) => { + let generation = -1; + // The key of the first state seen this generation, when that state is the snapshot itself. + let snapshotKey: string | null = null; + let keyInputs: { readonly threads: unknown; readonly removedAt: unknown } | null = null; + let key = ""; + let fromSnapshot: OlderSettled | null = null; + let lastRead: OlderSettled | null = null; + let lastReadPage: OrchestrationV2OlderSettledThreadsPage | null = null; + return Atom.make((get): OlderSettled | null => { + const state = shellStateValue(get(shellStateAtom(environmentId))); + const snapshot = state === null ? null : Option.getOrNull(state.snapshot); + const cut = snapshot?.olderSettled; + if (state === null || snapshot === null || cut === undefined) return null; + if (fromSnapshot?.before !== cut.before || state.generation !== generation) { + fromSnapshot = { + before: cut.before, + beforeMs: DateTime.toEpochMillis(DateTime.makeUnsafe(cut.before)), + counts: countsOf(cut), + }; + } + // Most shell updates move only the cursor; the key needs new rows or removals. + if (keyInputs?.threads !== snapshot.threads || keyInputs.removedAt !== state.removedAt) { + keyInputs = { threads: snapshot.threads, removedAt: state.removedAt }; + key = olderSettledCountKey(snapshot.threads, fromSnapshot.beforeMs, state.removedAt); + } + if (state.generation !== generation) { + generation = state.generation; + snapshotKey = snapshot.snapshotSequence === cut.asOfSequence ? key : null; + lastRead = null; + } + // Generation 0 is a cached shell: no reads until this session's first snapshot. + if (key === snapshotKey || state.generation === 0) return fromSnapshot; + const read = selectEnvironmentQueryData( + get( + countReadAtom({ + environmentId, + input: { generation: state.generation, before: cut.before, key }, + }), + ), + ); + if (read !== null && read !== lastReadPage) { + lastReadPage = read; + lastRead = { ...fromSnapshot, counts: countsOf(read) }; + } + return lastRead ?? fromSnapshot; + }).pipe(Atom.withLabel(`environment-older-settled:${environmentId}`)); + }); + + const pagesKey = (scope: OlderSettledScope, pages: number) => + JSON.stringify([ + scope.environmentId, + scope.projectIds === null ? null : [...scope.projectIds].sort(), + pages, + ]); + + const pagesAtom = Atom.family((key: string) => { + const [environmentId, projectIds, pages] = JSON.parse(key) as [ + EnvironmentId, + ReadonlyArray | null, + number, + ]; + let previous = NO_PAGES; + const keep = (next: OlderSettledPages): OlderSettledPages => { + const same = + next.status === previous.status && + next.frontier?.settledAt === previous.frontier?.settledAt && + next.frontier?.threadId === previous.frontier?.threadId && + (next.frontier === null) === (previous.frontier === null) && + next.reads.length === previous.reads.length && + next.reads.every((read, index) => read === previous.reads[index]); + if (!same) previous = next; + return previous; + }; + return Atom.make((get) => { + const state = shellStateValue(get(shellStateAtom(environmentId))); + const cut = state === null ? undefined : Option.getOrNull(state.snapshot)?.olderSettled; + // A cached shell (an environment not connected yet, or offline) must not hold the shelf + // back for other environments, so it lists what it holds until its first snapshot. + if (state === null || cut === undefined || state.generation === 0) return keep(NO_PAGES); + let frontier: OrchestrationV2OlderSettledCursor = { settledAt: cut.before }; + if (pages === 0) return keep({ reads: [], frontier, status: "ready" }); + const reads: OrchestrationV2ThreadFamily[] = []; + for (let index = 0; index < pages; index += 1) { + const result = get( + pageReadAtom({ + environmentId, + input: { generation: state.generation, projectIds, before: frontier }, + }), + ); + const page = selectEnvironmentQueryData(result); + if (page === null) { + return keep({ + reads, + frontier, + status: threadFamilyRead(result) === "failed" ? "failed" : "pending", + }); + } + reads.push(page); + const last = page.threads.at(-1); + if (page.threads.length < OLDER_SETTLED_PAGE_SIZE || last?.settledAt == null) { + return keep({ reads, frontier: null, status: "ready" }); + } + frontier = { settledAt: DateTime.formatIso(last.settledAt), threadId: last.id }; + } + return keep({ reads, frontier, status: "ready" }); + }).pipe(Atom.withLabel(`environment-older-settled-pages:${key}`)); + }); + + const pageRowsAtom = Atom.family((key: string) => + Atom.map(pagesAtom(key), (pages) => pages.reads).pipe( + Atom.withLabel(`environment-older-settled-page-rows:${key}`), + ), + ); + + const searchGenerationAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): number | "waiting" | "none" => { + const state = shellStateValue(get(shellStateAtom(environmentId))); + if (state !== null && state.generation > 0) return state.generation; + return state?.status === "synchronizing" ? "waiting" : "none"; + }), + ); + const searchReadAtom = createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:orchestration:older-settled-search", + idleTtlMs: 60_000, + execute: Effect.fn("OlderSettledThreads.search")(function* (input: { + readonly generation: number; + readonly search: OrchestrationV2SearchThreadsInput; + }) { + return yield* request(ORCHESTRATION_V2_WS_METHODS.searchThreads, input.search); + }), + }); + // Keyed by shell generation like pages: rows read before a new snapshot do not merge, so the + // search reads them again. + const searchAtom = Atom.family((key: string) => { + const [environmentId, search] = JSON.parse(key) as [ + EnvironmentId, + OrchestrationV2SearchThreadsInput, + ]; + return Atom.make((get) => { + const generation = get(searchGenerationAtom(environmentId)); + // Nothing is read before this session's first snapshot, which would drop the rows. It shows + // as loading only while that snapshot is on its way, so an offline environment never spins. + return typeof generation === "number" + ? get(searchReadAtom({ environmentId, input: { generation, search } })) + : AsyncResult.initial(generation === "waiting"); + }).pipe(Atom.withLabel(`environment-older-settled-search:${key}`)); + }); + + // One read for a shelf that spans environments: each one's cut, counts and pages. + const shelfAtom = Atom.family((key: string) => { + const entries = JSON.parse(key) as ReadonlyArray; + let previous: ReadonlyMap = new Map(); + return Atom.make((get): ReadonlyMap => { + const shelf = new Map(); + let changed = previous.size !== entries.length; + for (const [environmentId, pagesKey] of entries) { + const older = get(olderSettledAtom(environmentId as EnvironmentId)); + const pages = get(pagesAtom(pagesKey)); + const prior = previous.get(environmentId as EnvironmentId); + changed ||= prior?.older !== older || prior.pages !== pages; + shelf.set(environmentId as EnvironmentId, { older, pages }); + } + if (changed) previous = shelf; + return previous; + }).pipe(Atom.withLabel(`environment-older-settled-shelf:${key}`)); + }); + + /** Reads a failed page again (a no-op when none failed); the pages after it follow. */ + const retryPages = ( + registry: AtomRegistry.AtomRegistry, + scope: OlderSettledScope, + pages: number, + ) => { + const state = shellStateValue(registry.get(shellStateAtom(scope.environmentId))); + const result = registry.get(pagesAtom(pagesKey(scope, pages))); + if (state === null || result.status !== "failed" || result.frontier === null) return; + registry.refresh( + pageReadAtom({ + environmentId: scope.environmentId, + input: { + generation: state.generation, + projectIds: scope.projectIds === null ? null : [...scope.projectIds].sort(), + before: result.frontier, + }, + }), + ); + }; + + return { + /** The environment's cut and current counts, or null when its shell keeps every settled thread. */ + olderSettledAtom, + /** Each scope's cut, counts and first `pages` pages. */ + shelfAtom: ( + scopes: ReadonlyArray<{ readonly scope: OlderSettledScope; readonly pages: number }>, + ) => + shelfAtom( + JSON.stringify( + scopes.map(({ scope, pages }) => [scope.environmentId, pagesKey(scope, pages)] as const), + ), + ), + retryPages, + /** The first `pages` pages below the cut for one scope, read in order. */ + pagesAtom: (scope: OlderSettledScope, pages: number) => pagesAtom(pagesKey(scope, pages)), + /** Their rows, for `createThreadFamilyAtoms(...).mountRows`, under `rowsKey`. */ + pageRowsAtom: (scope: OlderSettledScope, pages: number) => pageRowsAtom(pagesKey(scope, pages)), + rowsKey: (scope: OlderSettledScope, pages: number) => `older-settled:${pagesKey(scope, pages)}`, + /** `orchestration.searchThreads` for a search that asks for older settled rows. */ + searchAtom: (environmentId: EnvironmentId, search: OrchestrationV2SearchThreadsInput) => + searchAtom(JSON.stringify([environmentId, search])), + }; +} diff --git a/packages/client-runtime/src/state/projectRemoval.ts b/packages/client-runtime/src/state/projectRemoval.ts index 55e1df257391..157c0901b124 100644 --- a/packages/client-runtime/src/state/projectRemoval.ts +++ b/packages/client-runtime/src/state/projectRemoval.ts @@ -12,19 +12,22 @@ export function planProjectRemoval< readonly hasOtherMembers: boolean; readonly displayName: string; readonly threads: ReadonlyArray; + /** The members' threads this client does not hold: older settled threads its shell left out. */ + readonly unloadedThreadCount?: number; }) { const { members, groupMemberCount, hasOtherMembers, displayName, threads } = options; const memberKeys = new Set(members.map((member) => `${member.environmentId}:${member.id}`)); const projectThreads = threads.filter((thread) => memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); + const threadCount = projectThreads.length + (options.unloadedThreadCount ?? 0); const isWholeGroup = members.length === groupMemberCount; const targetKind = hasOtherMembers || !isWholeGroup ? "checkout" : "project"; const singleMember = members.length === 1 ? members[0]! : null; const targetLabel = singleMember?.title ?? displayName; const confirmationMessage = [ - projectThreads.length > 0 - ? `Remove ${targetKind} "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` + threadCount > 0 + ? `Remove ${targetKind} "${targetLabel}" and delete its ${threadCount} thread${threadCount === 1 ? "" : "s"}?` : `Remove ${targetKind} "${targetLabel}"?`, ...(singleMember ? [ @@ -34,7 +37,7 @@ export function planProjectRemoval< : []), ] : [`This removes ${members.length} grouped project entries.`]), - ...(projectThreads.length > 0 + ...(threadCount > 0 ? ["This permanently clears conversation history for those threads and any archived threads."] : ["This permanently clears any archived conversation history."]), isWholeGroup && !hasOtherMembers diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 4ebde3f59fb7..4c9497ead86e 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -302,6 +302,14 @@ export function createPullRequestEnvironmentAtoms( refreshIntervalMs: 10_000, refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }), + // For callers that open a thread from the answer: a persisted one reads as final before + // the connection is known, and a kept one can predate the thread. + linkedThreadsUncached: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:linked-threads-uncached", + tag: WS_METHODS.pullRequestsLinkedThreads, + staleTimeMs: 0, + idleTtlMs: 0, + }), list: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:list", persistence: persistQuery(PullRequestListResult), diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 0e76e321bf3a..0883f3e2362c 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -478,7 +478,7 @@ describe("environment shell synchronization", () => { }), ); - it.effect("opts in to idle-child omission and counts authoritative snapshots and removals", () => + it.effect("opts in to shell omissions and counts authoritative snapshots and removals", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); const subscribeInput = yield* Deferred.make(); @@ -494,6 +494,7 @@ describe("environment shell synchronization", () => { initialConfig: Effect.succeed({ shellResumeCompletionMarker: true, shellThreadFamilies: true, + shellOlderSettled: true, } as never), }; const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ @@ -537,8 +538,11 @@ describe("environment shell synchronization", () => { expect(yield* Deferred.await(subscribeInput)).toMatchObject({ afterSequence: 1, omitIdleSubagents: true, + omitOlderSettled: true, }); - expect(yield* Ref.get(loadOptions)).toEqual([{ omitIdleSubagents: true }]); + expect(yield* Ref.get(loadOptions)).toEqual([ + { omitIdleSubagents: true, omitOlderSettled: true }, + ]); const loaded = Option.getOrThrow(yield* stateWhere((state) => state.generation > 0)); expect(loaded.removedAt.size).toBe(0); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index a01465d2dc7e..8c51179470e7 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -348,16 +348,20 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ORCHESTRATION_V2_WS_METHODS.subscribeShell, Effect.fn("EnvironmentShellState.makeSubscribeInput")(function* (session) { yield* Ref.set(activeSubscriptionSession, session); - const { supportsCompletionMarker, omitIdleSubagents } = yield* session.initialConfig.pipe( - Effect.map((config) => ({ - supportsCompletionMarker: config.shellResumeCompletionMarker === true, - omitIdleSubagents: config.shellThreadFamilies === true, - })), - Effect.orElseSucceed(() => ({ - supportsCompletionMarker: false, - omitIdleSubagents: false, - })), - ); + const { supportsCompletionMarker, omitIdleSubagents, omitOlderSettled } = + yield* session.initialConfig.pipe( + Effect.map((config) => ({ + supportsCompletionMarker: config.shellResumeCompletionMarker === true, + omitIdleSubagents: config.shellThreadFamilies === true, + omitOlderSettled: + config.shellOlderSettled === true && config.shellThreadFamilies === true, + })), + Effect.orElseSucceed(() => ({ + supportsCompletionMarker: false, + omitIdleSubagents: false, + omitOlderSettled: false, + })), + ); yield* Ref.set(awaitingCompletion, supportsCompletionMarker); yield* setSynchronizing; @@ -383,7 +387,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ), ); // HTTP and the socket must agree, or omitted children would return through the socket. - const httpSnapshot = yield* snapshotLoader.load(prepared, { omitIdleSubagents }); + const httpSnapshot = yield* snapshotLoader.load(prepared, { + omitIdleSubagents, + omitOlderSettled, + }); if (Option.isSome(httpSnapshot)) { yield* cancelActiveFill; const previous = yield* SubscriptionRef.get(state); @@ -405,7 +412,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } } - const omission = omitIdleSubagents ? { omitIdleSubagents: true as const } : {}; + const omission = { + ...(omitIdleSubagents ? { omitIdleSubagents: true as const } : {}), + ...(omitOlderSettled ? { omitOlderSettled: true as const } : {}), + }; if (!canResume || Option.isNone(current.snapshot)) { return { ...omission, diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts index cacab7b6724f..f30aa589581c 100644 --- a/packages/client-runtime/src/state/shellSnapshotHttp.ts +++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts @@ -34,9 +34,13 @@ export const fetchEnvironmentShellSnapshot = Effect.fn( >; readonly timeoutMs?: number; readonly omitIdleSubagents?: boolean; + readonly omitOlderSettled?: boolean; }) { const endpoint = { - query: input.omitIdleSubagents === true ? { omitIdleSubagents: "1" } : {}, + query: { + ...(input.omitIdleSubagents === true ? { omitIdleSubagents: "1" } : {}), + ...(input.omitOlderSettled === true ? { omitOlderSettled: "1" } : {}), + }, }; return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, @@ -85,7 +89,7 @@ export class ShellSnapshotLoader extends Context.Service< { readonly load: ( prepared: PreparedConnection, - options?: { readonly omitIdleSubagents?: boolean }, + options?: { readonly omitIdleSubagents?: boolean; readonly omitOlderSettled?: boolean }, ) => Effect.Effect>; } >()("@t3tools/client-runtime/state/shellSnapshotHttp/ShellSnapshotLoader") {} @@ -107,6 +111,7 @@ export const layer: Layer.Layer), Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/packages/client-runtime/src/state/threadFamilies.test.ts b/packages/client-runtime/src/state/threadFamilies.test.ts index e6d284edee36..2b2a4a6a1278 100644 --- a/packages/client-runtime/src/state/threadFamilies.test.ts +++ b/packages/client-runtime/src/state/threadFamilies.test.ts @@ -75,6 +75,49 @@ const TARGET = new PrimaryConnectionTarget({ }); describe("createThreadFamilyAtoms", () => { + it("merges mounted rows behind shell rows until released", () => { + const runtime = Atom.runtime( + Layer.succeed( + EnvironmentRegistry.EnvironmentRegistry, + {} as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"], + ), + ); + const shellState = Atom.make>( + AsyncResult.success({ + ...EMPTY_SHELL_STATE, + status: "live", + generation: 1, + snapshot: Option.some({ + schemaVersion: 1, + snapshotSequence: 9, + projects: [], + threads: [row("kept")], + archivedThreads: [], + olderSettled: { before: "2026-10-01T00:00:00.000Z", asOfSequence: 4, counts: [] }, + } as OrchestrationV2ShellSnapshot), + removedAt: new Map([[ThreadId.make("removed"), 9]]), + }), + ); + const families = createThreadFamilyAtoms(runtime, () => shellState); + const registry = AtomRegistry.make(); + const merged = () => registry.get(families.snapshotAtom(TARGET.environmentId))?.threads; + registry.mount(families.snapshotAtom(TARGET.environmentId)); + // A settled shelf page read at sequence 5: the shell's copy wins, a later removal drops a row. + const page = Atom.make>([ + { snapshotSequence: 5, threads: [row("kept", "page copy"), row("old"), row("removed")] }, + // Read before the latest authoritative snapshot (sequence 4), whose removals it cleared. + { snapshotSequence: 3, threads: [row("stale-search-row")] }, + ]); + const release = families.mountRows(registry, TARGET.environmentId, "older-settled:page", page); + expect(merged()?.map(({ id, title }) => [id, title])).toEqual([ + ["kept", "kept"], + ["old", "old"], + ]); + release(); + expect(merged()?.map(({ id }) => id)).toEqual(["kept"]); + registry.dispose(); + }); + it.effect("reads a family per shell generation and retries a failed read", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/threadFamilies.ts b/packages/client-runtime/src/state/threadFamilies.ts index 7a2860f8e700..0f40ac193946 100644 --- a/packages/client-runtime/src/state/threadFamilies.ts +++ b/packages/client-runtime/src/state/threadFamilies.ts @@ -19,7 +19,13 @@ import { createEnvironmentQueryAtomFamily, selectEnvironmentQueryData } from "./ import type { EnvironmentShellState } from "./shell.ts"; const EMPTY_FAMILY: OrchestrationV2ThreadFamily = { snapshotSequence: 0, threads: [] }; -const EMPTY_MOUNTS: ReadonlyMap = new Map(); +const EMPTY_READS: ReadonlyArray = []; + +interface MountedRows { + readonly rows: Atom.Atom>; + readonly count: number; +} +const EMPTY_MOUNTS: ReadonlyMap = new Map(); // Generation 0 is the cached shell from before this session's first authoritative snapshot. A read // for it would be thrown away once that snapshot lands, so it waits instead. const AWAITING_AUTHORITATIVE_SHELL = AsyncResult.initial(true); @@ -60,9 +66,10 @@ export function threadFamilyRowsMissingFromShell(input: { } /** - * Shells with `omitIdleSubagents` leave idle children to families: the subagent tree sharing a - * `lineage.rootThreadId`, read once per shell generation and never persisted. Family rows merge - * behind shell rows; shell deltas still carry every change, so a changed child's shell row wins. + * Shells with `omitIdleSubagents` or `omitOlderSettled` leave rows to on-demand reads: a family is + * every active thread sharing a `lineage.rootThreadId`, read once per shell generation; shelf pages + * and search rows mount through `mountRows`. All merge behind shell rows, which win (deltas carry + * every change), and are never persisted. */ export function createThreadFamilyAtoms( runtime: Atom.AtomRuntime, @@ -137,33 +144,58 @@ export function createThreadFamilyAtoms( ), ); - /** Keeps a thread's family in the merged rows until the returned release runs. */ - const mount = ( + const familyRowsAtom = Atom.family((key: string) => + Atom.make((get): ReadonlyArray => { + const family = selectEnvironmentQueryData(get(familyAtom(key))); + return family === null ? EMPTY_READS : [family]; + }).pipe(Atom.withLabel(`environment-thread-family-rows:${key}`)), + ); + + /** + * Merges `rows` behind the shell rows until the returned release runs. Reads read under `key` + * share one entry; each read's `snapshotSequence` decides which removals drop its rows. + */ + const mountRows = ( registry: AtomRegistry.AtomRegistry, environmentId: EnvironmentId, - threadId: ThreadId, + key: string, + rows: Atom.Atom>, ): (() => void) => { const atom = mountsAtom(environmentId); const mounts = registry.get(atom); - registry.set(atom, new Map(mounts).set(threadId, (mounts.get(threadId) ?? 0) + 1)); - // Mounting again after a failure retries instead of keeping the failure until reconnect. - if ( - threadFamilyRead(registry.get(familyAtom(familyKey(environmentId, threadId)))) === "failed" - ) { - refresh(registry, environmentId, threadId); - } + const entry = mounts.get(key); + registry.set( + atom, + new Map(mounts).set(key, { rows: entry?.rows ?? rows, count: (entry?.count ?? 0) + 1 }), + ); let released = false; return () => { if (released) return; released = true; const next = new Map(registry.get(atom)); - const count = (next.get(threadId) ?? 0) - 1; - if (count > 0) next.set(threadId, count); - else next.delete(threadId); + const entry = next.get(key); + if (entry !== undefined && entry.count > 1) + next.set(key, { ...entry, count: entry.count - 1 }); + else next.delete(key); registry.set(atom, next); }; }; + /** Keeps a thread's family in the merged rows until the returned release runs. */ + const mount = ( + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentId, + threadId: ThreadId, + ): (() => void) => { + const key = familyKey(environmentId, threadId); + const release = mountRows(registry, environmentId, `family:${key}`, familyRowsAtom(key)); + // Mounting again after a failure retries instead of keeping the failure until reconnect. + if (threadFamilyRead(registry.get(familyAtom(key))) === "failed") { + refresh(registry, environmentId, threadId); + } + return release; + }; + const snapshotAtom = Atom.family((environmentId: EnvironmentId) => { let previous: { readonly snapshot: OrchestrationV2ShellSnapshot; @@ -176,12 +208,12 @@ export function createThreadFamilyAtoms( const snapshot = state === null ? null : Option.getOrNull(state.snapshot); const mounts = get(mountsAtom(environmentId)); if (snapshot === null || state === null || mounts.size === 0) return snapshot; - const families = [...mounts.keys()].flatMap((threadId) => { - const family = selectEnvironmentQueryData( - get(familyAtom(familyKey(environmentId, threadId))), - ); - return family === null ? [] : [family]; - }); + // A read from before the latest authoritative snapshot can hold threads removed before it, + // and that snapshot cleared those removals; such reads (a cached search) wait for new ones. + const cut = snapshot.olderSettled?.asOfSequence ?? 0; + const families = [...mounts.values()].flatMap(({ rows }) => + get(rows).filter((read) => read.snapshotSequence >= cut), + ); // Most shell updates move only the cursor. With the same rows, removals and families, keep // the merged rows so thread lists skip their rebuild. const unchanged = @@ -219,6 +251,7 @@ export function createThreadFamilyAtoms( familyAtom: (environmentId: EnvironmentId, threadId: ThreadId) => familyAtom(familyKey(environmentId, threadId)), mount, + mountRows, refresh, }; } diff --git a/packages/client-runtime/src/state/threadSearch.test.ts b/packages/client-runtime/src/state/threadSearch.test.ts index 26c3d9cc01c7..b8d180e3a0ff 100644 --- a/packages/client-runtime/src/state/threadSearch.test.ts +++ b/packages/client-runtime/src/state/threadSearch.test.ts @@ -3,6 +3,8 @@ import { ProjectId, ThreadId, type OrchestrationSearchThreadsResult, + type OrchestrationV2SearchThreadsInput, + type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; @@ -55,15 +57,15 @@ it("ignores invalid search keys", () => { return Atom.make(AsyncResult.success({ matches: [] })); }, labelPrefix: "test:thread-search", - }); + }).results; const registry = AtomRegistry.make(); try { const state = registry.get(searchAtom(makeThreadSearchKey([envA], "a".repeat(201)))); const malformedState = registry.get(searchAtom("not json{{")); - expect(state).toEqual({ matches: [], isLoading: false }); - expect(malformedState).toEqual({ matches: [], isLoading: false }); + expect(state).toEqual({ matches: [], isLoading: false, rows: [] }); + expect(malformedState).toEqual({ matches: [], isLoading: false, rows: [] }); expect(searchCount).toBe(0); } finally { registry.dispose(); @@ -92,19 +94,63 @@ it("merges successful environments and silently ignores failures", () => { ), ), labelPrefix: "test:thread-search", - }); + }).results; const registry = AtomRegistry.make(); const state = registry.get(searchAtom(makeThreadSearchKey([envB, envA], "needle"))); expect(state).toEqual({ matches: [{ ...result.matches[0], environmentId: envA }], isLoading: false, + rows: [], }); expect(threadSearchMatchKey(state.matches[0]!)).toBe('["env-a","thread-a"]'); registry.dispose(); }); +it("asks environments that omit older settled threads for their rows, with the caller's scope", () => { + const inputs: Array = []; + const row = { id: ThreadId.make("thread-old") } as OrchestrationV2ThreadShell; + const search = createThreadSearchResultsAtomFamily({ + getSearchAtom: (environmentId, input) => { + inputs.push([environmentId, input]); + return Atom.make(AsyncResult.success({ matches: [], snapshotSequence: 9, threads: [row] })); + }, + olderSettledBefore: (environmentId) => + Atom.make(environmentId === envA ? "2026-10-01T00:00:00.000Z" : null), + labelPrefix: "test:thread-search", + }); + const registry = AtomRegistry.make(); + const scope = { + projectIds: [ProjectId.make("project-a")], + environmentLabels: { [envA]: "Laptop" }, + }; + + // Too short to match messages: only the environment with older rows is asked, for titles. + const key = makeThreadSearchKey([envA, envB], "ui", scope); + expect(registry.get(search.results(key)).rows).toEqual([ + { environmentId: envA, read: { snapshotSequence: 9, threads: [row] } }, + ]); + expect(registry.get(search.rows(key, envA))).toEqual([{ snapshotSequence: 9, threads: [row] }]); + expect(inputs).toEqual([ + [ + envA, + { + query: "ui", + olderSettledBefore: "2026-10-01T00:00:00.000Z", + projectIds: scope.projectIds, + environmentLabel: "Laptop", + titlesOnly: true, + }, + ], + ]); + inputs.length = 0; + registry.get(search.results(makeThreadSearchKey([envB], "needle"))); + expect(inputs).toEqual([[envB, { query: "needle" }]]); + + registry.dispose(); +}); + it("keeps the previous results visible only while a refined or widened query is pending", () => { const previous = { query: "websock", diff --git a/packages/client-runtime/src/state/threadSearch.ts b/packages/client-runtime/src/state/threadSearch.ts index 83534585255a..90a561f8cf78 100644 --- a/packages/client-runtime/src/state/threadSearch.ts +++ b/packages/client-runtime/src/state/threadSearch.ts @@ -1,14 +1,18 @@ import { EnvironmentId, OrchestrationSearchThreadsInput, - type OrchestrationSearchThreadsResult, + ProjectId, type OrchestrationThreadSearchMatch, + type OrchestrationV2SearchThreadsInput, + type OrchestrationV2SearchThreadsResult, + type OrchestrationV2ThreadFamily, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; import { foldSearchQuery, + messageSearchTokens, THREAD_OPEN_DEDUPE_MS, type ThreadSearchSubject, } from "@t3tools/shared/paletteSearch"; @@ -46,21 +50,46 @@ export interface EnvironmentThreadSearchMatch extends OrchestrationThreadSearchM export interface ThreadSearchResultsState { readonly matches: ReadonlyArray; readonly isLoading: boolean; + /** Rows of matching older settled threads, which shells that omit them do not hold. */ + readonly rows: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly read: OrchestrationV2ThreadFamily; + }>; } +/** + * How a search reaches environments whose shells omit older settled threads (others ignore it): + * the caller's project scope and its names for environments, which its own ranking matches, and + * whether to skip message search. + */ +export const ThreadSearchScope = Schema.Struct({ + projectIds: Schema.optionalKey(Schema.Array(ProjectId)), + environmentLabels: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + titlesOnly: Schema.optionalKey(Schema.Boolean), +}); +export type ThreadSearchScope = typeof ThreadSearchScope.Type; + const ThreadSearchKey = Schema.fromJsonString( - Schema.Tuple([Schema.Array(EnvironmentId), OrchestrationSearchThreadsInput.fields.query]), + Schema.Union([ + Schema.Tuple([Schema.Array(EnvironmentId), OrchestrationSearchThreadsInput.fields.query]), + Schema.Tuple([ + Schema.Array(EnvironmentId), + OrchestrationSearchThreadsInput.fields.query, + ThreadSearchScope, + ]), + ]), ); const decodeThreadSearchKey = Schema.decodeUnknownOption(ThreadSearchKey); +const EMPTY_SEARCH_ROWS: ThreadSearchResultsState["rows"] = []; +const EMPTY_READS: ReadonlyArray = []; export function makeThreadSearchKey( environmentIds: ReadonlyArray, query: string, + scope: ThreadSearchScope = {}, ): string { - return JSON.stringify([ - [...environmentIds].sort((left, right) => left.localeCompare(right)), - query, - ]); + const sorted = [...environmentIds].sort((left, right) => left.localeCompare(right)); + return JSON.stringify(Object.keys(scope).length === 0 ? [sorted, query] : [sorted, query, scope]); } function parseThreadSearchKey(key: string) { @@ -73,36 +102,88 @@ export function threadSearchMatchKey( return JSON.stringify([match.environmentId, match.threadId]); } +/** + * Message matches from every environment, plus, from environments whose shells omit older settled + * threads (`olderSettledBefore` is set), the rows of the best older matches. Callers mount those + * rows (`rows`) so their own ranking sees them, as it sees shell rows. + */ export function createThreadSearchResultsAtomFamily(options: { readonly getSearchAtom: ( environmentId: EnvironmentId, - query: string, - ) => Atom.Atom>; + input: OrchestrationV2SearchThreadsInput, + ) => Atom.Atom>; + readonly olderSettledBefore?: (environmentId: EnvironmentId) => Atom.Atom; readonly labelPrefix: string; }) { - return Atom.family((key: string) => + const results = Atom.family((key: string) => Atom.make((get): ThreadSearchResultsState => { const parsedKey = parseThreadSearchKey(key); if (Option.isNone(parsedKey)) { - return { matches: [], isLoading: false }; + return { matches: [], isLoading: false, rows: EMPTY_SEARCH_ROWS }; } - const [environmentIds, query] = parsedKey.value; + const [environmentIds, query, scope = {}] = parsedKey.value; + // The server needs a word of three or more letters to match messages. + const searchMessages = + scope.titlesOnly !== true && messageSearchTokens(query).long.length > 0; const matches: EnvironmentThreadSearchMatch[] = []; + const rows: Array = []; let isLoading = false; for (const environmentId of environmentIds) { - const result = get(options.getSearchAtom(environmentId, query)); + const before = options.olderSettledBefore + ? get(options.olderSettledBefore(environmentId)) + : null; + if (!searchMessages && before === null) continue; + const label = scope.environmentLabels?.[environmentId]; + const result = get( + options.getSearchAtom(environmentId, { + query, + ...(before === null + ? {} + : { + olderSettledBefore: before, + ...(scope.projectIds === undefined ? {} : { projectIds: scope.projectIds }), + ...(label === undefined ? {} : { environmentLabel: label }), + ...(searchMessages ? {} : { titlesOnly: true }), + }), + }), + ); isLoading ||= result.waiting; const value = Option.getOrNull(AsyncResult.value(result)); if (value !== null) { matches.push(...value.matches.map((match) => ({ ...match, environmentId }))); + if (value.threads !== undefined && value.threads.length > 0) { + rows.push({ + environmentId, + read: { snapshotSequence: value.snapshotSequence ?? 0, threads: value.threads }, + }); + } } } - return { matches, isLoading }; + return { matches, isLoading, rows: rows.length === 0 ? EMPTY_SEARCH_ROWS : rows }; }).pipe(Atom.withLabel(`${options.labelPrefix}:${key}`)), ); + const rowsAtom = Atom.family((rowsKey: string) => { + const [key, environmentId] = JSON.parse(rowsKey) as [string, EnvironmentId]; + let previous = EMPTY_READS; + return Atom.make((get) => { + const next = get(results(key)).rows.flatMap((row) => + row.environmentId === environmentId ? [row.read] : [], + ); + if (next.length === previous.length && next.every((read, index) => read === previous[index])) + return previous; + previous = next.length === 0 ? EMPTY_READS : next; + return previous; + }).pipe(Atom.withLabel(`${options.labelPrefix}-rows:${rowsKey}`)); + }); + return { + results, + /** One environment's older settled rows for a search key, for `mountRows`. */ + rows: (key: string, environmentId: EnvironmentId) => + rowsAtom(JSON.stringify([key, environmentId])), + }; } export type ThreadSearchShell = Pick< diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index 8a7bb349a904..7a5818174dcd 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -58,7 +58,9 @@ export function sortSettledThreads - right.timestampMs - left.timestampMs || left.thread.id.localeCompare(right.thread.id), + right.timestampMs - left.timestampMs || + // Plain order, as the server pages ties (ids mix upper and lower case). + (left.thread.id < right.thread.id ? -1 : left.thread.id > right.thread.id ? 1 : 0), ) .map(({ thread }) => thread); } diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 3bfa1ffbb409..e5519373c92b 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -1053,5 +1053,6 @@ export * from "./threadCommands.ts"; export * from "./threadFeedback.ts"; export * from "./threadDetail.ts"; export * from "./threadFamilies.ts"; +export * from "./olderSettledThreads.ts"; export * from "./threadShell.ts"; export * from "./threadState.ts"; diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 51634d78eef9..330e292181d4 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -669,6 +669,7 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ const EnvironmentOrchestrationShellSnapshotQuery = Schema.Struct({ omitIdleSubagents: Schema.optionalKey(Schema.String), + omitOlderSettled: Schema.optionalKey(Schema.String), }); const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 6aa4ea7bbe66..189a44d2db9f 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1,5 +1,6 @@ import { OrchestrationMessageContext } from "./composerContext.ts"; import { withRecordIdTable } from "./orchestrationV2IdTable.ts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SchemaAST from "effect/SchemaAST"; @@ -41,6 +42,10 @@ import { } from "./baseSchemas.ts"; import { ChatAttachment } from "./chatAttachment.ts"; import { ThreadGuestShare } from "./guestShare.ts"; +import { + OrchestrationSearchThreadsInput, + OrchestrationSearchThreadsResult, +} from "./threadSearch.ts"; import { OrchestrationGetFullThreadDiffInput, OrchestrationGetFullThreadDiffResult, @@ -1988,6 +1993,40 @@ export function isLiveSubagentShell( ); } +/** + * A settled top-level thread settled before `beforeMs`. Shells that omit older settled threads + * count every such thread, so the server and clients share this one rule. + */ +export function isOlderSettledThreadShell( + thread: Pick, + beforeMs: number, +): boolean { + return ( + thread.lineage.relationshipToParent !== "subagent" && + thread.settledOverride === "settled" && + thread.settledAt !== null && + DateTime.toEpochMillis(thread.settledAt) < beforeMs + ); +} + +export const OrchestrationV2OlderSettledCount = Schema.Struct({ + projectId: ProjectId, + count: NonNegativeInt, +}); +export type OrchestrationV2OlderSettledCount = typeof OrchestrationV2OlderSettledCount.Type; + +/** + * Snapshots that leave out older settled threads (`omitOlderSettled`) name the cut and count, per + * project, every thread `isOlderSettledThreadShell` matches, sent or not. `asOfSequence` is the + * snapshot's own sequence: the counts are exact until a later change. + */ +export const OrchestrationV2OlderSettledThreads = Schema.Struct({ + before: IsoDateTime, + asOfSequence: NonNegativeInt, + counts: Schema.Array(OrchestrationV2OlderSettledCount), +}); +export type OrchestrationV2OlderSettledThreads = typeof OrchestrationV2OlderSettledThreads.Type; + export const OrchestrationV2ThreadShellSnapshot = Schema.Struct({ schemaVersion: PositiveInt, snapshotSequence: NonNegativeInt, @@ -1999,6 +2038,7 @@ export type OrchestrationV2ThreadShellSnapshot = typeof OrchestrationV2ThreadShe export const OrchestrationV2ShellSnapshot = Schema.Struct({ ...OrchestrationV2ThreadShellSnapshot.fields, projects: Schema.Array(OrchestrationProjectShell), + olderSettled: Schema.optionalKey(OrchestrationV2OlderSettledThreads), }); export type OrchestrationV2ShellSnapshot = typeof OrchestrationV2ShellSnapshot.Type; @@ -3247,6 +3287,7 @@ export const ORCHESTRATION_V2_WS_METHODS = { getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", getThreadFamily: "orchestration.getThreadFamily", getWorktreeThreadIds: "orchestration.getWorktreeThreadIds", + getOlderSettledThreads: "orchestration.getOlderSettledThreads", getThreadProjection: "orchestration.getThreadProjection", getWorkflowScript: "orchestration.getWorkflowScript", stopWorkflow: "orchestration.stopWorkflow", @@ -3361,6 +3402,11 @@ export const OrchestrationV2SubscribeShellInput = Schema.Struct({ * servers that advertise `shellThreadFamilies`. */ omitIdleSubagents: Schema.optionalKey(Schema.Boolean), + /** + * Snapshots leave out older settled threads and count them in `olderSettled`; read them with + * `getOlderSettledThreads`. Send only to servers that advertise `shellOlderSettled`. + */ + omitOlderSettled: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationV2SubscribeShellInput = typeof OrchestrationV2SubscribeShellInput.Type; @@ -3370,9 +3416,9 @@ export const OrchestrationV2GetThreadFamilyInput = Schema.Struct({ export type OrchestrationV2GetThreadFamilyInput = typeof OrchestrationV2GetThreadFamilyInput.Type; /** - * The active subagent threads that share the requested thread's `lineage.rootThreadId`: its whole - * subagent tree, in any run state. An unknown thread has an empty family; a deleted one still - * names its root. + * The active threads that share the requested thread's `lineage.rootThreadId`: the root, its + * forks, and their whole subagent trees, in any run state. An unknown thread has an empty family; + * a deleted one still names its root. */ export const OrchestrationV2ThreadFamily = Schema.Struct({ snapshotSequence: NonNegativeInt, @@ -3386,6 +3432,60 @@ export const OrchestrationV2GetWorktreeThreadIdsInput = Schema.Struct({ export type OrchestrationV2GetWorktreeThreadIdsInput = typeof OrchestrationV2GetWorktreeThreadIdsInput.Type; +/** + * The client's thread search. With `olderSettledBefore`, the result also carries the rows of the + * best matching threads settled before it, which shells that omit older settled threads leave out: + * title, branch, pull request and project matches (in `projectIds` when given), then message hits. + * Send the extra keys only to servers that advertise `shellOlderSettled`. + */ +export const OrchestrationV2SearchThreadsInput = Schema.Struct({ + ...OrchestrationSearchThreadsInput.fields, + olderSettledBefore: Schema.optionalKey(IsoDateTime), + projectIds: Schema.optionalKey(Schema.Array(ProjectId)), + /** The client's name for this environment, which its own ranking also matches. */ + environmentLabel: Schema.optionalKey(Schema.String), + /** Skips message search, for pickers that match titles only. */ + titlesOnly: Schema.optionalKey(Schema.Boolean), +}); +export type OrchestrationV2SearchThreadsInput = typeof OrchestrationV2SearchThreadsInput.Type; + +export const OrchestrationV2SearchThreadsResult = Schema.Struct({ + ...OrchestrationSearchThreadsResult.fields, + snapshotSequence: Schema.optionalKey(NonNegativeInt), + threads: Schema.optionalKey(Schema.Array(OrchestrationV2ThreadShell)), +}); +export type OrchestrationV2SearchThreadsResult = typeof OrchestrationV2SearchThreadsResult.Type; + +/** Where a page of older settled threads starts: below this settle time, then after this id. */ +export const OrchestrationV2OlderSettledCursor = Schema.Struct({ + settledAt: IsoDateTime, + threadId: Schema.optionalKey(ThreadId), +}); +export type OrchestrationV2OlderSettledCursor = typeof OrchestrationV2OlderSettledCursor.Type; + +export const OrchestrationV2GetOlderSettledThreadsInput = Schema.Struct({ + before: OrchestrationV2OlderSettledCursor, + /** Every project when absent. */ + projectIds: Schema.optionalKey(Schema.Array(ProjectId)), + /** 0 reads only the counts. */ + limit: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 50 })), +}); +export type OrchestrationV2GetOlderSettledThreadsInput = + typeof OrchestrationV2GetOlderSettledThreadsInput.Type; + +/** + * Older settled threads below the cursor, newest settle first (`settledAt` desc, then id asc). A + * `limit: 0` read fills `counts` instead: the older settled threads below the cursor per project, + * as `OrchestrationV2OlderSettledThreads.counts` count them. A cursor without an id is strict. + */ +export const OrchestrationV2OlderSettledThreadsPage = Schema.Struct({ + snapshotSequence: NonNegativeInt, + threads: Schema.Array(OrchestrationV2ThreadShell), + counts: Schema.Array(OrchestrationV2OlderSettledCount), +}); +export type OrchestrationV2OlderSettledThreadsPage = + typeof OrchestrationV2OlderSettledThreadsPage.Type; + /** Active threads on a worktree path, including subagent children a shell may leave out. */ export const OrchestrationV2WorktreeThreadIds = Schema.Struct({ threadIds: Schema.Array(ThreadId), @@ -3778,6 +3878,10 @@ export const OrchestrationV2RpcSchemas = { input: OrchestrationV2GetWorktreeThreadIdsInput, output: OrchestrationV2WorktreeThreadIds, }, + getOlderSettledThreads: { + input: OrchestrationV2GetOlderSettledThreadsInput, + output: OrchestrationV2OlderSettledThreadsPage, + }, getThreadProjection: { input: OrchestrationV2GetThreadProjectionInput, output: OrchestrationV2ThreadProjection, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 8f06d235a324..025d3c0ce966 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,6 +2,8 @@ import { OrchestrationV2SearchThreadError, OrchestrationV2SearchThreadInput, OrchestrationV2SearchThreadResult, + OrchestrationV2SearchThreadsInput, + OrchestrationV2SearchThreadsResult, } from "./orchestrationV2.ts"; import { OrchestrationDispatchCommandError } from "./orchestrationDispatch.ts"; import { @@ -150,11 +152,7 @@ import { ReviewDiffPreviewResult, } from "./review.ts"; import { KeybindingsConfigError } from "./keybindings.ts"; -import { - OrchestrationSearchThreadsError, - OrchestrationSearchThreadsInput, - OrchestrationSearchThreadsResult, -} from "./threadSearch.ts"; +import { OrchestrationSearchThreadsError } from "./threadSearch.ts"; import { ProviderUploadFeedbackError, ProviderUploadFeedbackInput, @@ -1661,8 +1659,8 @@ const WsOrchestrationV2SearchThreadStreamRpc = Rpc.make( ); const WsOrchestrationV2SearchThreadsRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.searchThreads, { - payload: OrchestrationSearchThreadsInput, - success: OrchestrationSearchThreadsResult, + payload: OrchestrationV2SearchThreadsInput, + success: OrchestrationV2SearchThreadsResult, error: Schema.Union([OrchestrationSearchThreadsError, EnvironmentAuthorizationError]), }); @@ -1690,6 +1688,15 @@ const WsOrchestrationV2GetWorktreeThreadIdsRpc = Rpc.make( }, ); +const WsOrchestrationV2GetOlderSettledThreadsRpc = Rpc.make( + ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads, + { + payload: OrchestrationV2RpcSchemas.getOlderSettledThreads.input, + success: OrchestrationV2RpcSchemas.getOlderSettledThreads.output, + error: Schema.Union([OrchestrationV2GetShellSnapshotError, EnvironmentAuthorizationError]), + }, +); + const WsOrchestrationV2GetThreadProjectionRpc = Rpc.make( ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { @@ -2092,6 +2099,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationV2GetArchivedShellSnapshotRpc, WsOrchestrationV2GetThreadFamilyRpc, WsOrchestrationV2GetWorktreeThreadIdsRpc, + WsOrchestrationV2GetOlderSettledThreadsRpc, WsOrchestrationV2GetThreadProjectionRpc, WsOrchestrationV2LaunchThreadRpc, WsOrchestrationV2SubscribeArchivedShellRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 909458f5f03b..c14b4b4b24ea 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -599,6 +599,11 @@ export const ServerConfig = Schema.Struct({ * `getThreadFamily` and `getWorktreeThreadIds` serve the rows it leaves out. */ shellThreadFamilies: Schema.optionalKey(Schema.Boolean), + /** + * The shell can leave older settled threads out of snapshots (`omitOlderSettled`), and + * `getOlderSettledThreads` serves them. Only servers that also advertise `shellThreadFamilies`. + */ + shellOlderSettled: Schema.optionalKey(Schema.Boolean), shellRevealInFileManager: Schema.optionalKey(Schema.Boolean), shellRevealInFileManagerKind: Schema.optionalKey(FileManagerRevealKind), threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean),