Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1305,6 +1305,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
<ThreadFeed
environmentId={props.environmentId}
threadId={props.selectedThread.id}
detailRef={props.selectedThreadDetailRef}
workspaceRoot={props.threadCwd}
feed={props.selectedThreadFeed}
pendingHandoff={props.pendingHandoff}
Expand Down
11 changes: 5 additions & 6 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
type OrchestrationV2ProjectedTurnItem,
type RunId,
type ScheduledTaskId,
type ScopedThreadRef,
} from "@t3tools/contracts";
import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
Expand Down Expand Up @@ -343,6 +344,8 @@ export interface ThreadFeedProps {
readonly onEditPendingMessage: ((message: QueuedThreadMessage) => void) | null;
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
/** Null while the thread's creation is queued: reading detail then caches a not-found as deleted. */
readonly detailRef: ScopedThreadRef | null;
readonly threadTitle: string;
readonly workspaceRoot?: string | null;
readonly feed: ReadonlyArray<ThreadFeedEntry>;
Expand Down Expand Up @@ -2124,12 +2127,8 @@ export const ThreadFeed = memo(function ThreadFeed({
...props
}: ThreadFeedProps & { readonly ref?: Ref<ThreadFeedHandle> }) {
const { listRef, onEndFollowEnabledChange, onHeaderMaterialVisibilityChange } = props;
const workflowOpening = useWorkflowOpening(props.environmentId, props.threadId);
const guestThreadRef = useMemo(
() => scopeThreadRef(props.environmentId, props.threadId),
[props.environmentId, props.threadId],
);
const guestShare = useThreadGuestShare(guestThreadRef);
const workflowOpening = useWorkflowOpening(props.detailRef);
const guestShare = useThreadGuestShare(props.detailRef);
const deviceType = useDeviceType();
const duoColumns = useNativeWorkspaceColumnsSupported() && isDuoDevice(deviceType);
const openPullRequest = useOpenPullRequest();
Expand Down
14 changes: 10 additions & 4 deletions apps/mobile/src/features/threads/thread-workflow-opening.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
workflowOpeningParentThreadId,
type RuntimeSubagent,
} from "@t3tools/client-runtime/state/subagentRuntime";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell";
import { Atom } from "effect/reactivity";
import { useState } from "react";
import { Pressable, View } from "react-native";

Expand All @@ -20,8 +22,10 @@ import { useOpenLinkedThread } from "./useOpenLinkedThread";

const FOLDED_SCRIPT_LINES = 10;

export function useWorkflowOpening(environmentId: EnvironmentId, threadId: ThreadId) {
const threadAtom = environmentThreadDetails.threadAtom(scopeThreadRef(environmentId, threadId));
const NO_THREAD_ATOM = Atom.make<EnvironmentThread | null>(null);

export function useWorkflowOpening(ref: ScopedThreadRef | null) {
const threadAtom = ref === null ? NO_THREAD_ATOM : environmentThreadDetails.threadAtom(ref);
// Selected as a primitive so an ordinary thread's updates never change the result.
const parentId = useAtomValue(threadAtom, (thread) =>
thread === null
Expand All @@ -35,7 +39,9 @@ export function useWorkflowOpening(environmentId: EnvironmentId, threadId: Threa
parentId === null ? null : (thread?.projection ?? null),
);
const parentSubagents = useAtomValue(
environmentThreadDetails.threadAtom(scopeThreadRef(environmentId, parentId ?? threadId)),
ref === null || parentId === null
? NO_THREAD_ATOM
: environmentThreadDetails.threadAtom(scopeThreadRef(ref.environmentId, parentId)),
(parent) => (parentId === null ? null : (parent?.projection.subagents ?? null)),
);
return projection === null
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/lib/filePreview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ describe("ThreadInlineFilePreview playback", () => {
createElement(ThreadFeed, {
environmentId,
threadId,
detailRef: null,
threadTitle: "preview",
workspaceRoot: "/repo",
feed,
Expand Down Expand Up @@ -1384,6 +1385,7 @@ describe("ThreadFeed previews", () => {
createElement(ThreadFeed, {
environmentId,
threadId,
detailRef: null,
threadTitle: "preview",
workspaceRoot: "/repo",
feed: feedFor(threadId),
Expand Down
9 changes: 1 addition & 8 deletions apps/mobile/src/lib/scopedEntities.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EnvironmentId, ProjectId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";

export function scopedProjectKey(environmentId: EnvironmentId, projectId: ProjectId): string {
return `${environmentId}:${projectId}`;
Expand All @@ -7,10 +7,3 @@ export function scopedProjectKey(environmentId: EnvironmentId, projectId: Projec
export function scopedThreadKey(environmentId: EnvironmentId, threadId: ThreadId): string {
return `${environmentId}:${threadId}`;
}

export function scopedRequestKey(
environmentId: EnvironmentId,
requestId: RuntimeRequestId,
): string {
return `${environmentId}:${requestId}`;
}
49 changes: 49 additions & 0 deletions apps/mobile/src/state/pending-thread-creation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test";

import {
resolvePendingThreadCreation,
selectThreadCreationDetail,
type PendingThreadCreation,
} from "./pending-thread-creation";
import type { QueuedThreadMessage } from "./thread-outbox-model";
Expand Down Expand Up @@ -138,3 +139,51 @@ describe("resolvePendingThreadCreation", () => {
).toBeNull();
});
});

describe("selectThreadCreationDetail", () => {
const threadKey = `${creation.environmentId}:${creation.threadId}`;
const threadRef = { environmentId: creation.environmentId, threadId: creation.threadId };

it("withholds the detail ref until the creation is delivered", () => {
const queued = selectThreadCreationDetail({
threadRef,
threadKey,
queuedMessagesByThreadKey: { [threadKey]: [creation] },
outcomes: {},
});
expect(queued).toEqual({
pendingCreation: { message: creation, outcome: null },
detailRef: null,
});

const failed = selectThreadCreationDetail({
threadRef,
threadKey,
queuedMessagesByThreadKey: {},
outcomes: { [threadKey]: { kind: "failed", message: creation, reason: "Checkout failed" } },
});
expect(failed.pendingCreation?.outcome?.kind).toBe("failed");
expect(failed.detailRef).toBeNull();

const delivered = selectThreadCreationDetail({
threadRef,
threadKey,
queuedMessagesByThreadKey: {},
outcomes: { [threadKey]: { kind: "delivered", message: creation } },
});
expect(delivered.pendingCreation?.outcome?.kind).toBe("delivered");
expect(delivered.detailRef).toBe(threadRef);
});

it("does not hold back existing threads with only follow-ups queued", () => {
const { creation: _creation, ...followUp } = creation;
expect(
selectThreadCreationDetail({
threadRef,
threadKey,
queuedMessagesByThreadKey: { [threadKey]: [followUp] },
outcomes: {},
}),
).toEqual({ pendingCreation: null, detailRef: threadRef });
});
});
29 changes: 29 additions & 0 deletions apps/mobile/src/state/pending-thread-creation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,35 @@ export type PendingThreadCreation = {
readonly outcome: PendingThreadCreationOutcome | null;
};

export function selectThreadCreationDetail<Ref>(input: {
readonly threadRef: Ref | null;
readonly threadKey: string | null;
readonly queuedMessagesByThreadKey: Readonly<
Record<string, ReadonlyArray<QueuedThreadMessage> | undefined>
>;
readonly outcomes: Readonly<Record<string, PendingThreadCreationOutcome | undefined>>;
}): { readonly pendingCreation: PendingThreadCreation | null; readonly detailRef: Ref | null } {
const pendingCreation = ((): PendingThreadCreation | null => {
if (input.threadKey === null) {
return null;
}
const queued = input.queuedMessagesByThreadKey[input.threadKey]?.find(
(message) => message.creation !== undefined,
);
const outcome = input.outcomes[input.threadKey] ?? null;
const message = queued ?? outcome?.message ?? null;
return message === null ? null : { message, outcome };
})();
// The shell can arrive before creation is acknowledged. Wait for delivery
// before subscribing: an early not-found is cached as a deleted thread.
// Every detail reader must use this ref, not the route id.
const detailRef =
pendingCreation === null || pendingCreation.outcome?.kind === "delivered"
? input.threadRef
: null;
return { pendingCreation, detailRef };
}

export function resolvePendingThreadCreation(input: {
readonly threadKey: string | null;
readonly pending: PendingThreadCreation | null;
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/state/thread-task-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ vi.mock("./threads", async () => {
environmentThreadDetails: {
queueWorkflowAtom: () => Atom.make(null),
checkpointSummariesAtom: () => Atom.make([]),
pendingRequestsAtom: () => Atom.make(state.pendingRequests),
},
threadEnvironment: {
respondToApproval: state.approve,
Expand Down
Loading
Loading