diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000000..bf28b1b342d2 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,9 @@ +reviews: + high_level_summary: false + review_status: false + auto_review: + enabled: true + path_filters: + # Vendored read-only reference checkouts of upstream Effect and Alchemy + # (see scripts/lib/reference-repos.ts). Nothing imports from them. + - "!.repos/**" diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index e3e8d3d0eb12..d6d55ef563b8 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -43,7 +43,7 @@ import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSe function PullRequestScrollView(props: ScrollViewProps) { const insets = useSafeAreaInsets(); return ( - + (null).pipe( + Atom.withLabel("mobile-thread-turn-subagents:empty"), +); + +export function useThreadTurnSubagents(target: AgentsTarget | null): ThreadTurnSubagents | null { + return useAtomValue( + target === null + ? EMPTY_TURN_SUBAGENTS_ATOM + : environmentThreadDetails.turnSubagentsAtom(target), + ); } export function ThreadAgentsSheet({ route }: StaticScreenProps) { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 1707debb95ee..97663da1ba97 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -26,6 +26,7 @@ import type { ProviderInteractionMode, RuntimeMode, RuntimeRequestId, + ScopedThreadRef, ServerConfig as T3ServerConfig, ThreadId, UsageLimitsReport, @@ -157,6 +158,11 @@ export interface ThreadDetailScreenProps { readonly setupWorkingStartedAt?: string | null; readonly canOperateThread: boolean; readonly selectedThread: EnvironmentThreadShell; + /** + * Read thread details through this, not `selectedThread.id`: it stays null until + * a queued creation reaches the server; see `useThreadSelection`. + */ + readonly selectedThreadDetailRef: ScopedThreadRef | null; readonly contentPresentation: ThreadContentPresentation; readonly screenTone: StatusTone; readonly connectionError: string | null; @@ -333,10 +339,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const navigation = useNavigation(); const { session: voiceInputSession } = useGlobalVoiceInput(); - const reportedModelSelection = useThreadReportedModelSelection({ - environmentId: props.environmentId, - threadId: props.selectedThread.id, - }); + const reportedModelSelection = useThreadReportedModelSelection(props.selectedThreadDetailRef); const deviceState = useEnvironmentQuery( deviceEnvironment.state({ environmentId: props.environmentId, input: {} }), ); @@ -414,14 +417,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread threadId: props.selectedThread.id, }); const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; - const queuedCount = useThreadQueuedCount({ - environmentId: props.environmentId, - threadId: props.selectedThread.id, - }); - const turnSubagents = useThreadTurnSubagents({ - environmentId: props.environmentId, - threadId: props.selectedThread.id, - }); + const queuedCount = useThreadQueuedCount(props.selectedThreadDetailRef); + const turnSubagents = useThreadTurnSubagents(props.selectedThreadDetailRef); const agentsSegment = resolveSubagentPillSegment(turnSubagents); const composerEditorRef = useRef(null); // A provider-native subagent shows status instead of a composer. diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index e8d81a20a8bc..441b1bfe3c69 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -1,6 +1,7 @@ import { type StaticScreenProps, useNavigation } from "@react-navigation/native"; import { useAtomValue } from "@effect/atom-react"; import type { ChatAttachment, EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; import { Image } from "expo-image"; import * as Haptics from "expo-haptics"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -40,6 +41,9 @@ import { threadDragGapOffset } from "./threadDragGap"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); const REMOVE_ACTION_WIDTH = 76; const THUMBNAIL_LIMIT = 3; +const EMPTY_QUEUED_COUNT_ATOM = Atom.make(0).pipe( + Atom.withLabel("mobile-thread-queue-count:empty"), +); type QueueTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId }; type QueueAction = "steer" | "edit" | "up" | "down" | "remove"; @@ -49,8 +53,10 @@ export function useThreadQueueWorkflow(target: QueueTarget) { return useAtomValue(environmentThreadDetails.queueWorkflowAtom(target)); } -export function useThreadQueuedCount(target: QueueTarget) { - return useAtomValue(environmentThreadDetails.queuedCountAtom(target)); +export function useThreadQueuedCount(target: QueueTarget | null) { + return useAtomValue( + target === null ? EMPTY_QUEUED_COUNT_ATOM : environmentThreadDetails.queuedCountAtom(target), + ); } export function ThreadQueueSheet({ route }: StaticScreenProps) { diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 348f7377724b..001dbbd2b0ea 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -274,6 +274,7 @@ function ThreadRouteContent( const { selectedThread, selectedThreadCreation, + selectedThreadDetailRef, selectedThreadProject, selectedEnvironmentConnection, } = useThreadSelection(); @@ -988,6 +989,7 @@ function ThreadRouteContent( { expect(context.records).toEqual([terminal, review, pr]); }); }); + +describe("threadComposerContext", () => { + it("mints a schema-valid, stable id for agent-spawned thread ids", () => { + const environmentId = EnvironmentId.make("env-1"); + const ref = { + environmentId, + threadId: ThreadId.make(`thread:mcp:${encodeURIComponent("3f2b:delegate")}:review:0`), + }; + const contextId = threadComposerContext(ref, "Review").contextId; + + expect(() => ComposerContextId.make(contextId)).not.toThrow(); + expect(threadComposerContext(ref, "Renamed").contextId).toBe(contextId); + expect( + threadComposerContext({ environmentId, threadId: ThreadId.make("abc-123") }, "t").contextId, + ).toBe("thread_abc-123"); + }); +}); diff --git a/apps/mobile/src/lib/composerContext.ts b/apps/mobile/src/lib/composerContext.ts index 45b033dc9488..16a790d65e4c 100644 --- a/apps/mobile/src/lib/composerContext.ts +++ b/apps/mobile/src/lib/composerContext.ts @@ -20,6 +20,7 @@ import { isImageReferencesOnly, replaceComposerContextReferences, sanitizeComposerContextLabel, + toKindScopedComposerContextId, } from "@t3tools/shared/composerContextReferences"; import { collectComposerInlineTokens, @@ -110,7 +111,7 @@ export function threadComposerContext(ref: ScopedThreadRef, title: string): Thre return { version: 1, kind: "thread", - contextId: ComposerContextId.make(`thread_${ref.threadId}`), + contextId: toKindScopedComposerContextId("thread", ref.threadId), label, environmentId: ref.environmentId, threadId: ref.threadId, diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index b8e42b0cb99e..31b19d8574a6 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -31,6 +31,9 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).p const EMPTY_SERVER_CONFIG_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-server-config:empty"), ); +const EMPTY_THREAD_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-thread:empty"), +); const EMPTY_THREAD_TITLES: ReadonlyMap = new Map(); const EMPTY_THREAD_TITLES_ATOM = Atom.make(EMPTY_THREAD_TITLES).pipe( Atom.withLabel("mobile-thread-titles:empty"), @@ -133,6 +136,9 @@ export function useServerConfigs(): ReadonlyMap { const selectReportedModelSelection = (thread: EnvironmentThread | null) => thread === null ? null : deriveReportedModelSelection(thread.projection); -export function useThreadReportedModelSelection(ref: ScopedThreadRef) { - return useAtomValue(environmentThreadDetails.threadAtom(ref), selectReportedModelSelection); +export function useThreadReportedModelSelection(ref: ScopedThreadRef | null) { + return useAtomValue( + ref === null ? EMPTY_THREAD_ATOM : environmentThreadDetails.threadAtom(ref), + selectReportedModelSelection, + ); } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 623eff1b0a5e..10ef09b7b77a 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -184,6 +184,7 @@ export function useThreadComposerState() { const { selectedThread: selectedThreadShell, selectedThreadCreation, + selectedThreadDetailRef, selectedEnvironmentRuntime, } = useThreadSelection(); const selectedThreadProjection = useSelectedThreadProjection(); @@ -311,12 +312,9 @@ export function useThreadComposerState() { // Steering needs a live provider turn the adapter can interrupt; the queue // workflow already derives that from the session's capabilities. const queueWorkflow = useAtomValue( - selectedThreadShell === null + selectedThreadDetailRef === null ? EMPTY_QUEUE_WORKFLOW_ATOM - : environmentThreadDetails.queueWorkflowAtom({ - environmentId: selectedThreadShell.environmentId, - threadId: selectedThreadShell.id, - }), + : environmentThreadDetails.queueWorkflowAtom(selectedThreadDetailRef), ); const canSteerActiveTurn = queueWorkflow?.canPromoteToSteer === true; const queuedRunEdit = useQueuedRunEdit(selectedThreadKey); diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 3624caebb076..51b88c748e4a 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -158,7 +158,8 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin return message === null ? null : { message, outcome }; }, [creationOutcome, queuedMessagesByThreadKey, selectedThreadKey]); // Until the creation is delivered the server has no thread to subscribe - // to; subscribing anyway would retry "not found" for the whole setup. + // to. Every detail reader must use this ref, not the route id: an earlier + // read gets a definitive not-found, which the thread state keeps as deleted. const selectedThreadDetailRef = selectedThreadShell !== null || pendingCreation === null || diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f775123d49e..822e79da8dbd 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -199,7 +199,10 @@ import { type TerminalContextSelection, } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; -import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { + replaceComposerContextReferences, + toKindScopedComposerContextId, +} from "@t3tools/shared/composerContextReferences"; import { getRestingComposerImagePreviewCounts, resolveRestingComposerControlsLayout, @@ -228,7 +231,6 @@ import { formatInlineContextReference, insertInlineContextReference, inlineContextReferenceReplacement, - toKindScopedComposerContextId, } from "~/lib/composerContextReferences"; import { asKnownContextRecord, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 3ac4952e89c8..99cf502959a6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1766,6 +1766,24 @@ function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { return target instanceof Element && target.closest("[data-minimap-preview]") !== null; } +// Calls `onRelease` once no mouse button is held. A release outside the window +// can skip `mouseup`, so the next button-free `mousemove` also counts. Returns a +// function that stops listening. +function onMouseButtonsReleased(onRelease: () => void): () => void { + const check = (event: globalThis.MouseEvent) => { + if (event.buttons !== 0) return; + stop(); + onRelease(); + }; + const stop = () => { + window.removeEventListener("mouseup", check); + window.removeEventListener("mousemove", check); + }; + window.addEventListener("mouseup", check); + window.addEventListener("mousemove", check); + return stop; +} + function TimelineMinimap({ hasPersistentGutter, hitStripWidth, @@ -1793,6 +1811,18 @@ function TimelineMinimap({ [onWheel], ); + // A held drag that started on the minimap (the strip or its turn buttons) is a + // scrub; one that started anywhere else is a text selection. While a selection + // crosses the minimap, the minimap lets the pointer through so the selection + // keeps following the text underneath, until the button is released. + const pressedOnStripRef = useRef(false); + const [passThrough, setPassThrough] = useState(false); + + useEffect(() => { + if (!passThrough) return; + return onMouseButtonsReleased(() => setPassThrough(false)); + }, [passThrough]); + const resolvedActiveIndex = activeIndex !== null && activeIndex < items.length ? activeIndex : null; const activeItem = useMemo( @@ -1802,7 +1832,8 @@ function TimelineMinimap({ ), [items, resolvedActiveIndex], ); - const navigationInteractive = resolveTimelineMinimapNavigationInteractive(hitStripWidth); + const navigationInteractive = + resolveTimelineMinimapNavigationInteractive(hitStripWidth) && !passThrough; const activeTopPercent = resolvedActiveIndex === null ? 0 @@ -1874,8 +1905,18 @@ function TimelineMinimap({ "absolute top-1/2 left-3 -translate-y-1/2", // The strip is width-capped to the side gutter so it never overlays // the centered content column; with no usable gutter it goes inert. - hitStripWidth > 0 ? "pointer-events-auto" : "pointer-events-none", + hitStripWidth > 0 && !passThrough ? "pointer-events-auto" : "pointer-events-none", )} + onMouseDown={() => { + if (pressedOnStripRef.current) return; + pressedOnStripRef.current = true; + onMouseButtonsReleased(() => { + pressedOnStripRef.current = false; + }); + }} + onMouseMove={(event) => { + if (event.buttons !== 0 && !pressedOnStripRef.current) setPassThrough(true); + }} style={{ height: resolveTimelineMinimapHeightStyle(items.length), width: resolveTimelineMinimapInteractiveWidth(hitStripWidth, activeItem !== null), @@ -1926,7 +1967,10 @@ function TimelineMinimap({ } }} onMouseLeave={() => setActiveIndex(null)} - onMouseMove={updateActiveIndexFromPointer} + onMouseMove={(event) => { + if (event.buttons !== 0 && !pressedOnStripRef.current) return; + updateActiveIndexFromPointer(event); + }} onMouseDown={(event) => { if (timelineMinimapEventTargetsPreview(event.target)) { return; diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 4e4f04f2ec54..eaac1cba352f 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "vite-plus/test"; import { pullRequestHostActions, + largestPullRequestReadCount, pullRequestQuickActions, filterPullRequestsByInvolvement, findScopedProject, @@ -1759,3 +1760,24 @@ describe("pull request quick actions", () => { ]); }); }); + +describe("refresh page size", () => { + const twoPagesAcrossFourRepositories = (provider: "github" | "gitlab") => + ["a", "b", "c", "d"].flatMap((name) => + Array.from({ length: 198 }, (_, index) => + entry({ + number: index + 1, + provider: SourceControlProviderKind.make(provider), + repository: `pingdotgg/${name}`, + }), + ), + ); + + it("asks each repository for its own rows where repositories are read one at a time", () => { + expect(largestPullRequestReadCount(twoPagesAcrossFourRepositories("gitlab"))).toBe(198); + }); + + it("asks for the whole host where one search reads every repository", () => { + expect(largestPullRequestReadCount(twoPagesAcrossFourRepositories("github"))).toBe(792); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index f56929c5dbc8..9f01d87601ba 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -1304,3 +1304,16 @@ export function pullRequestQuickActions( : ["close", "merge"]; return forState.filter((action) => hostActions.has(action)); } + +export function largestPullRequestReadCount( + entries: ReadonlyArray, +): number { + const counts = new Map(); + for (const entry of entries) { + const read = `${entry.environmentId} ${entry.host.toLowerCase()} ${ + entry.provider === "github" ? "" : entry.repository.toLowerCase() + }`; + counts.set(read, (counts.get(read) ?? 0) + 1); + } + return Math.max(0, ...counts.values()); +} diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f5663094da62..1dfecfc4d076 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -60,8 +60,6 @@ import { ensureInlineContextReferences, formatInlineContextReference, removeInlineContextReference, - toComposerContextId, - toKindScopedComposerContextId, } from "./lib/composerContextReferences"; import { fileContextReference, @@ -77,7 +75,11 @@ import { persist, type PersistStorage, type StorageValue } from "zustand/middlew import { useShallow } from "zustand/react/shallow"; import { createDeferredStorage, createMemoryStorage } from "./lib/storage"; import { getDefaultServerModel } from "./providerModels"; -import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { + replaceComposerContextReferences, + toComposerContextId, + toKindScopedComposerContextId, +} from "@t3tools/shared/composerContextReferences"; import { UnifiedSettings } from "@t3tools/contracts/settings"; import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewCommentContext"; const isRuntimeMode = Schema.is(RuntimeMode); diff --git a/apps/web/src/lib/composerContextRecords.ts b/apps/web/src/lib/composerContextRecords.ts index e3091de9d76f..af743dd1b91e 100644 --- a/apps/web/src/lib/composerContextRecords.ts +++ b/apps/web/src/lib/composerContextRecords.ts @@ -24,12 +24,12 @@ import { encodeComposerContextFragment } from "@t3tools/shared/composerContextCl import { collectComposerContextReferences, sanitizeComposerContextLabel, + toKindScopedComposerContextId, } from "@t3tools/shared/composerContextReferences"; import { type ComposerContextReference, producerIdFromComposerContextId, - toKindScopedComposerContextId, } from "./composerContextReferences"; import type { ComposerFileAttachment, ComposerImageAttachment } from "~/composerDraftStore"; import type { AttachmentUploadState } from "./attachmentUploadState"; diff --git a/apps/web/src/lib/composerContextReferences.test.ts b/apps/web/src/lib/composerContextReferences.test.ts index 2d70b6bfa915..53965d6c697f 100644 --- a/apps/web/src/lib/composerContextReferences.test.ts +++ b/apps/web/src/lib/composerContextReferences.test.ts @@ -9,8 +9,6 @@ import { inlineContextReferenceReplacement, removeInlineContextReference, stripInlineContextReferences, - toComposerContextId, - toKindScopedComposerContextId, } from "./composerContextReferences"; const review = { kind: "review-comment", contextId: "rc-1", label: "a.ts L4" }; @@ -63,27 +61,3 @@ describe("composerContextReferences", () => { expect(ensureInlineContextReferences(reviewLink, [review])).toBe(reviewLink); }); }); - -describe("toComposerContextId", () => { - it("keeps same-kind raw IDs distinct when one contains the namespace prefix", () => { - expect(toKindScopedComposerContextId("image", "x")).toBe("image_x"); - expect(toKindScopedComposerContextId("image", "image_x")).toBe("image_image_x"); - }); - it("keeps ids that already fit the grammar and folds the rest deterministically", () => { - expect(toComposerContextId("file-comment-1700-1")).toBe("file-comment-1700-1"); - const folded = toComposerContextId("pull-request-selection:src/a.ts:4-9"); - expect(folded).toMatch(/^pull-request-selection-src-a-ts-4-9-[0-9a-f]{16}$/); - expect(toComposerContextId("pull-request-selection:src/a.ts:4-9")).toBe(folded); - expect(toComposerContextId("pull-request-selection:src/b.ts:4-9")).not.toBe(folded); - expect(toComposerContextId("::")).toMatch(/^ctx-[0-9a-f]{16}$/); - }); - it("tells apart producer ids that agree past the slug's truncation point", () => { - // The slug keeps 48 characters, so only the digest distinguishes these two. - const shared = `pull-request-finding:${"a".repeat(60)}`; - const first = toComposerContextId(`${shared}:1`); - const second = toComposerContextId(`${shared}:2`); - - expect(first).not.toBe(second); - expect(first.length).toBeLessThanOrEqual(128); - }); -}); diff --git a/apps/web/src/lib/composerContextReferences.ts b/apps/web/src/lib/composerContextReferences.ts index b80e021a3cab..68197bef6e4e 100644 --- a/apps/web/src/lib/composerContextReferences.ts +++ b/apps/web/src/lib/composerContextReferences.ts @@ -17,49 +17,6 @@ export interface ComposerContextReference { label: string; } -const CONTEXT_ID_PATTERN = /^[a-z0-9_-]{1,128}$/i; - -/** - * Two independent FNV-1a passes, one forward and one with a different offset basis over the - * reversed input. 64 bits of digest, because the slug in front of it is truncated: two long - * producer ids that agree on their first 48 characters are told apart by this alone. - */ -function fnv1a64(value: string): string { - let forward = 0x811c9dc5; - let reverse = 0x9dc5811c; - for (let index = 0; index < value.length; index += 1) { - forward ^= value.charCodeAt(index); - forward = Math.imul(forward, 0x01000193) >>> 0; - reverse ^= value.charCodeAt(value.length - 1 - index); - reverse = Math.imul(reverse, 0x01000193) >>> 0; - } - return `${forward.toString(16).padStart(8, "0")}${reverse.toString(16).padStart(8, "0")}`; -} - -/** - * Producers mint ids in their own grammars (`pull-request-finding:42`, - * `file-comment--`). A context id must survive a Markdown link and the wire - * schema, so anything outside `[a-z0-9_-]` is folded into a readable slug plus a hash of - * the original. Deterministic, so the same producer id always maps to the same context id. - */ -export function toComposerContextId(producerId: string): ComposerContextId { - if (CONTEXT_ID_PATTERN.test(producerId)) return producerId as ComposerContextId; - const slug = producerId - .replace(/[^a-z0-9_-]+/gi, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 48); - return `${slug || "ctx"}-${fnv1a64(producerId)}` as ComposerContextId; -} - -/** Raw producer IDs are always scoped, even when they already start with the kind name. */ -export function toKindScopedComposerContextId( - kind: ComposerContextKind, - producerId: string, -): ComposerContextId { - const prefix = `${kind}_`; - return toComposerContextId(`${prefix}${producerId}`); -} - /** Only for importing canonical records: undo one namespace before rebuilding a draft. */ export function producerIdFromComposerContextId( kind: ComposerContextKind, diff --git a/apps/web/src/lib/terminalContext.ts b/apps/web/src/lib/terminalContext.ts index 553a3550581b..92e5222fcb8d 100644 --- a/apps/web/src/lib/terminalContext.ts +++ b/apps/web/src/lib/terminalContext.ts @@ -1,6 +1,8 @@ import type { ThreadId } from "@t3tools/contracts"; -import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; -import { toKindScopedComposerContextId } from "./composerContextReferences"; +import { + formatComposerContextReference, + toKindScopedComposerContextId, +} from "@t3tools/shared/composerContextReferences"; export interface TerminalContextSelection { terminalId: string; diff --git a/apps/web/src/lib/threadContextAttachment.ts b/apps/web/src/lib/threadContextAttachment.ts index 474ccac3819b..996b444d7f76 100644 --- a/apps/web/src/lib/threadContextAttachment.ts +++ b/apps/web/src/lib/threadContextAttachment.ts @@ -1,9 +1,8 @@ import type { EnvironmentId, OrchestrationV2ThreadTranscript } from "@t3tools/contracts"; +import { toKindScopedComposerContextId } from "@t3tools/shared/composerContextReferences"; import { threadTranscriptHeader } from "@t3tools/shared/threadTranscript"; import * as DateTime from "effect/DateTime"; -import { toKindScopedComposerContextId } from "./composerContextReferences"; - export function threadContextAttachment( environmentId: EnvironmentId, transcript: OrchestrationV2ThreadTranscript, diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index f7154177f57b..cb97e39f1dad 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -91,6 +91,7 @@ import { reusePullRequestEntries, settlePullRequestOverrides, pullRequestHostActions, + largestPullRequestReadCount, } from "../components/pullRequest/pullRequestList.logic"; import { pullRequestListPreferences, @@ -1238,7 +1239,8 @@ function PullRequestsRouteView() { return; } if (related.length > 0) listQuery.refresh(related); - const loadedCount = ordered?.key === filterKey ? ordered.entries.length : pageSize; + const loadedCount = + ordered?.key === filterKey ? largestPullRequestReadCount(ordered.entries) : pageSize; setPage({ key: filterKey, regrown: [], diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 01adb9951546..b0bd3303755c 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -825,6 +825,16 @@ describe("CursorAdapterV2", () => { sandboxEnabled: true, }, ); + assert.deepEqual( + cursorRuntimeAgentPolicy({ + ...base, + runtimeMode: "auto", + }), + { + autoReview: true, + sandboxEnabled: true, + }, + ); assert.deepEqual( cursorRuntimeAgentPolicy({ ...base, diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index b77ec70ddcac..a43ed2901a52 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -192,7 +192,7 @@ export function cursorRuntimeAgentPolicy( return { autoReview: runtimePolicy.approvalPolicy === undefined - ? runtimePolicy.runtimeMode === "approval-required" + ? runtimePolicy.runtimeMode === "approval-required" || runtimePolicy.runtimeMode === "auto" : runtimePolicy.approvalPolicy !== "never", sandboxEnabled: runtimePolicy.sandboxPolicy === undefined diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts index 8b59a6c261b9..f79cd8b2bc58 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -11,6 +11,8 @@ import { projectComposerContextForProvider, replaceComposerContextReferences, sanitizeComposerContextLabel, + toComposerContextId, + toKindScopedComposerContextId, } from "./composerContextReferences.ts"; const ctx = (value: string) => value as ComposerContextId; @@ -298,3 +300,27 @@ describe("provider projection", () => { expect(projected).not.toContain("boom"); }); }); + +describe("toComposerContextId", () => { + it("keeps same-kind raw IDs distinct when one contains the namespace prefix", () => { + expect(toKindScopedComposerContextId("image", "x")).toBe("image_x"); + expect(toKindScopedComposerContextId("image", "image_x")).toBe("image_image_x"); + }); + it("keeps ids that already fit the grammar and folds the rest deterministically", () => { + expect(toComposerContextId("file-comment-1700-1")).toBe("file-comment-1700-1"); + const folded = toComposerContextId("pull-request-selection:src/a.ts:4-9"); + expect(folded).toMatch(/^pull-request-selection-src-a-ts-4-9-[0-9a-f]{16}$/); + expect(toComposerContextId("pull-request-selection:src/a.ts:4-9")).toBe(folded); + expect(toComposerContextId("pull-request-selection:src/b.ts:4-9")).not.toBe(folded); + expect(toComposerContextId("::")).toMatch(/^ctx-[0-9a-f]{16}$/); + }); + it("tells apart producer ids that agree past the slug's truncation point", () => { + // The slug keeps 48 characters, so only the digest distinguishes these two. + const shared = `pull-request-finding:${"a".repeat(60)}`; + const first = toComposerContextId(`${shared}:1`); + const second = toComposerContextId(`${shared}:2`); + + expect(first).not.toBe(second); + expect(first.length).toBeLessThanOrEqual(128); + }); +}); diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index ae5864899080..ef83dac8c9f1 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -23,6 +23,47 @@ const CONTEXT_LINK = new RegExp( "g", ); +/** + * Two independent FNV-1a passes, one forward and one with a different offset basis over the + * reversed input. 64 bits of digest, because the slug in front of it is truncated: two long + * producer ids that agree on their first 48 characters are told apart by this alone. + */ +function fnv1a64(value: string): string { + let forward = 0x811c9dc5; + let reverse = 0x9dc5811c; + for (let index = 0; index < value.length; index += 1) { + forward ^= value.charCodeAt(index); + forward = Math.imul(forward, 0x01000193) >>> 0; + reverse ^= value.charCodeAt(value.length - 1 - index); + reverse = Math.imul(reverse, 0x01000193) >>> 0; + } + return `${forward.toString(16).padStart(8, "0")}${reverse.toString(16).padStart(8, "0")}`; +} + +/** + * Producers mint ids in their own grammars (`pull-request-finding:42`, + * `file-comment--`). A context id must survive a Markdown link and the wire + * schema, so anything outside `[a-z0-9_-]` is folded into a readable slug plus a hash of + * the original. Deterministic, so the same producer id always maps to the same context id. + */ +export function toComposerContextId(producerId: string): ComposerContextId { + if (CONTEXT_ID_PATTERN.test(producerId)) return producerId as ComposerContextId; + const slug = producerId + .replace(/[^a-z0-9_-]+/gi, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); + return `${slug || "ctx"}-${fnv1a64(producerId)}` as ComposerContextId; +} + +/** Raw producer IDs are always scoped, even when they already start with the kind name. */ +export function toKindScopedComposerContextId( + kind: ComposerContextKind, + producerId: string, +): ComposerContextId { + const prefix = `${kind}_`; + return toComposerContextId(`${prefix}${producerId}`); +} + export function formatComposerContextHref(kind: ComposerContextKind, contextId: ComposerContextId) { return `${COMPOSER_CONTEXT_HREF_PREFIX}${kind}/${contextId}`; }