Skip to content
Source
Sign inSign up
Open History to see the last commit

usePullRequestThreads.ts

189 lines7.5 KB

1import { useAtomValue } from "@effect/atom-react";
2import { visibleLinkedThreads } from "@t3tools/client-runtime/state/threads";
3import { AsyncResult } from "effect/reactivity";
4import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5import * as Dialog from "../../components/ui/Dialog";
6import {
7 StackActions,
8 useNavigation,
9 useNavigationState,
10 useRoute,
11} from "@react-navigation/native";
12import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility";
13import {
14 AuthOrchestrationOperateScope,
15 AuthSourceControlWriteScope,
16 ThreadId,
17 type PullRequestDetail,
18 type PullRequestLinkedThreadsResult,
19} from "@t3tools/contracts";
20import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
21import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
22import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pull-requests";
23import { useRef, useState } from "react";
24import { pullRequestComposerContext } from "../../lib/composerContext";
25import { scopedThreadKey } from "../../lib/scopedEntities";
26import { uuidv4 } from "../../lib/uuid";
27import { useEnvironmentServerConfig } from "../../state/entities";
28import { composerPullRequests } from "../../state/pull-requests";
29import { useEnvironmentQuery } from "../../state/query";
30import { environmentThreadShells } from "../../state/threads";
31import { useEnvironmentScope } from "../../state/session";
32import {
33 createNewTaskDraft,
34 getComposerDraftSnapshot,
35 insertComposerDraftContext,
36 setComposerDraftText,
37 updateComposerDraftSettings,
38 waitForComposerDraftsLoaded,
39} from "../../state/use-composer-drafts";
40import { usePullRequestConnected } from "./usePullRequestConnected";
41import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
42import {
43 appendPullRequestAgentPrompt,
44 pullRequestAgentPrompt,
45 type PullRequestAgentTask,
46} from "./pullRequestAgentTasks";
47​
48type LinkedThread = PullRequestLinkedThreadsResult["threads"][number];
49​
50export function usePullRequestThreads(
51 params: PullRequestParams,
52 pr: PullRequestDetail | null,
53 filePath?: string,
54) {
55 const target = pullRequestTarget(params);
56 const config = useEnvironmentServerConfig(target.environmentId);
57 const supported = threadPullRequestLinkMode(config?.environment.capabilities) === "multiple";
58 const parsed = pr ? sourceControlClients.parseChangeRequestUrl(pr.url)?.link : undefined;
59 const reference = {
60 ...target,
61 input: { ...target.input, ...(parsed ? normalizeThreadPullRequestKey(parsed) : {}) },
62 };
63 const key = JSON.stringify(reference);
64 const route = useRoute();
65 // The iOS synthetic workspace parent's focus differs from the visible detail's focus.
66 const focused = useNavigationState((state) => state.routes[state.index]?.key === route.key);
67 const relations = useEnvironmentQuery(
68 pr && supported && focused ? composerPullRequests.linkedThreads(reference) : null,
69 );
70 const [lastRelations, setLastRelations] = useState({ key, data: relations.data });
71 if (relations.data && (lastRelations.key !== key || relations.data !== lastRelations.data))
72 setLastRelations({ key, data: relations.data });
73 const hiddenKeys = useAtomValue(environmentThreadShells.hiddenThreadKeysAtom);
74 const hidingAnywhere = useAtomValue(environmentThreadShells.anyEnvironmentHidingAtom);
75 const loadedThreadRefs = useAtomValue(
76 environmentThreadShells.environmentThreadRefsAtom(target.environmentId),
77 );
78 const threads = visibleLinkedThreads(
79 (relations.data ?? (lastRelations.key === key ? lastRelations.data : null))?.threads ?? [],
80 target.environmentId,
81 loadedThreadRefs,
82 hiddenKeys,
83 hidingAnywhere,
84 );
85 const connected = usePullRequestConnected(params.environmentId);
86 const canOperate = useEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope);
87 const canAsk = !connected || canOperate;
88 const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope);
89 const navigation = useNavigation();
90 const pending = useRef(false);
91 const previousPrompts = useRef(new Map<string, string>());
92 const openThread = (thread: LinkedThread) => {
93 openThreadRoute({ environmentId: params.environmentId, threadId: thread.id });
94 };
95 const openThreadRoute = (destination: ReactNavigation.RootParamList["Thread"]) => {
96 navigation.dispatch(StackActions.push("Thread", destination));
97 };
98 const [busy, setBusy] = useState(false);
99 const ask = async (thread?: LinkedThread, task: PullRequestAgentTask = "ask") => {
100 if (!pr || !canAsk || pending.current) return;
101 const terminology = sourceControlClients.get(pr.provider).changeRequest;
102 pending.current = true;
103 setBusy(true);
104 const result = await settlePromise(async () => {
105 if (connected && !thread && !canCheckout)
106 throw new Error(
107 `Starting work from this ${terminology.shortLabel} requires source control write access.`,
108 );
109 await waitForComposerDraftsLoaded();
110 const draftKey = thread
111 ? scopedThreadKey(target.environmentId, ThreadId.make(thread.id))
112 : createNewTaskDraft({
113 environmentId: target.environmentId,
114 projectId: target.input.projectId,
115 });
116 if (!thread)
117 updateComposerDraftSettings(draftKey, {
118 workspaceSelection: {
119 mode: "worktree",
120 branch: pr.headBranch,
121 worktreePath: null,
122 startFromOrigin: false,
123 pullRequestReference: pr.url,
124 },
125 });
126 const context = pullRequestComposerContext(
127 {
128 number: pr.number,
129 title: pr.title,
130 url: pr.url,
131 headBranch: pr.headBranch,
132 baseBranch: pr.baseBranch,
133 state: pr.state,
134 isDraft: pr.isDraft,
135 },
136 uuidv4(),
137 filePath,
138 );
139 const draft = getComposerDraftSnapshot(draftKey);
140 const attached = draft.context?.records.some(
141 (record) =>
142 record.kind === "review-comment" &&
143 "pullRequest" in record &&
144 record.pullRequest?.url === pr.url &&
145 record.filePath === context.filePath,
146 );
147 if (
148 !attached &&
149 !insertComposerDraftContext(
150 draftKey,
151 {
152 text: `${formatComposerContextReference(context)} `,
153 context: { version: 1, records: [context] },
154 },
155 { text: draft.text, start: draft.text.length, end: draft.text.length },
156 )
157 )
158 throw new Error("Remove some context from this thread's draft, then try again.");
159 const withContext = getComposerDraftSnapshot(draftKey);
160 setComposerDraftText(
161 draftKey,
162 appendPullRequestAgentPrompt(
163 withContext.text,
164 task,
165 terminology,
166 previousPrompts.current.get(draftKey),
167 ),
168 );
169 previousPrompts.current.set(draftKey, pullRequestAgentPrompt(task, terminology));
170 if (thread) openThread(thread);
171 else openThreadRoute({ draft: { draftId: draftKey, title: `Ask about #${pr.number}` } });
172 });
173 pending.current = false;
174 setBusy(false);
175 if (AsyncResult.isFailure(result)) {
176 const cause = squashAtomCommandFailure(result);
177 Dialog.alert("Couldn't open a thread", cause instanceof Error ? cause.message : "Try again.");
178 }
179 };
180 return {
181 ...relations,
182 threads,
183 canAsk,
184 canWatch: config?.environment.capabilities.threadPullRequestWatch === true,
185 busy,
186 ask,
187 openThread,
188 };