Skip to content
9 changes: 9 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
@@ -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/**"
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSe
function PullRequestScrollView(props: ScrollViewProps) {
const insets = useSafeAreaInsets();
return (
<ScrollViewMarker>
<ScrollViewMarker style={{ flex: 1 }}>
<ScrollView
{...props}
automaticallyAdjustContentInsets={false}
Expand Down
13 changes: 11 additions & 2 deletions apps/mobile/src/features/threads/ThreadAgentsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { deriveSubagentElapsedMs, formatDuration } from "@t3tools/shared/orchestrationTiming";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import * as DateTime from "effect/DateTime";
import { Atom } from "effect/reactivity";
import * as Haptics from "expo-haptics";
import { useEffect, useState } from "react";
import { Platform, Pressable, ScrollView, View } from "react-native";
Expand All @@ -28,8 +29,16 @@ const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Pl

type AgentsTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId };

export function useThreadTurnSubagents(target: AgentsTarget): ThreadTurnSubagents | null {
return useAtomValue(environmentThreadDetails.turnSubagentsAtom(target));
const EMPTY_TURN_SUBAGENTS_ATOM = Atom.make<ThreadTurnSubagents | null>(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<AgentsTarget>) {
Expand Down
21 changes: 9 additions & 12 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
ProviderInteractionMode,
RuntimeMode,
RuntimeRequestId,
ScopedThreadRef,
ServerConfig as T3ServerConfig,
ThreadId,
UsageLimitsReport,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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: {} }),
);
Expand Down Expand Up @@ -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<ComposerEditorHandle>(null);
// A provider-native subagent shows status instead of a composer.
Expand Down
10 changes: 8 additions & 2 deletions apps/mobile/src/features/threads/ThreadQueueControl.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";
Expand All @@ -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<QueueTarget>) {
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ function ThreadRouteContent(
const {
selectedThread,
selectedThreadCreation,
selectedThreadDetailRef,
selectedThreadProject,
selectedEnvironmentConnection,
} = useThreadSelection();
Expand Down Expand Up @@ -988,6 +989,7 @@ function ThreadRouteContent(
<ThreadDetailScreen
canOperateThread={canOperateThread}
selectedThread={selectedThreadWithDraftSettings ?? selectedThread}
selectedThreadDetailRef={selectedThreadDetailRef}
contentPresentation={contentPresentation}
screenTone={connectionTone(routeConnectionState)}
connectionError={routeConnectionError}
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/lib/composerContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
ProjectId,
ProviderInstanceId,
ComposerContextId,
EnvironmentId,
ThreadId,
type OrchestrationMessageContext,
} from "@t3tools/contracts";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
Expand All @@ -24,6 +26,7 @@ import {
uploadedComposerContext,
serializeComposerMessageForServer,
pullRequestComposerContext,
threadComposerContext,
} from "./composerContext";

const terminal = {
Expand Down Expand Up @@ -318,3 +321,20 @@ describe("host context compatibility", () => {
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");
});
});
3 changes: 2 additions & 1 deletion apps/mobile/src/lib/composerContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
isImageReferencesOnly,
replaceComposerContextReferences,
sanitizeComposerContextLabel,
toKindScopedComposerContextId,
} from "@t3tools/shared/composerContextReferences";
import {
collectComposerInlineTokens,
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions apps/mobile/src/state/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).p
const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe(
Atom.withLabel("mobile-server-config:empty"),
);
const EMPTY_THREAD_ATOM = Atom.make<EnvironmentThread | null>(null).pipe(
Atom.withLabel("mobile-thread:empty"),
);
const EMPTY_THREAD_TITLES: ReadonlyMap<ThreadId, string> = new Map();
const EMPTY_THREAD_TITLES_ATOM = Atom.make(EMPTY_THREAD_TITLES).pipe(
Atom.withLabel("mobile-thread-titles:empty"),
Expand Down Expand Up @@ -133,6 +136,9 @@ export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> {
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,
);
}
8 changes: 3 additions & 5 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export function useThreadComposerState() {
const {
selectedThread: selectedThreadShell,
selectedThreadCreation,
selectedThreadDetailRef,
selectedEnvironmentRuntime,
} = useThreadSelection();
const selectedThreadProjection = useSelectedThreadProjection();
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/state/use-thread-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -228,7 +231,6 @@ import {
formatInlineContextReference,
insertInlineContextReference,
inlineContextReferenceReplacement,
toKindScopedComposerContextId,
} from "~/lib/composerContextReferences";
import {
asKnownContextRecord,
Expand Down
50 changes: 47 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -1802,7 +1832,8 @@ function TimelineMinimap({
),
[items, resolvedActiveIndex],
);
const navigationInteractive = resolveTimelineMinimapNavigationInteractive(hitStripWidth);
const navigationInteractive =
resolveTimelineMinimapNavigationInteractive(hitStripWidth) && !passThrough;
const activeTopPercent =
resolvedActiveIndex === null
? 0
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/components/pullRequest/pullRequestList.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { describe, expect, it } from "vite-plus/test";

import {
pullRequestHostActions,
largestPullRequestReadCount,
pullRequestQuickActions,
filterPullRequestsByInvolvement,
findScopedProject,
Expand Down Expand Up @@ -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);
});
});
Loading