authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 20:30:11-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 20:30:11-07:00
log2eda8ac6a3151b70b8ee49b438bfb6fcd835802e
tree18587141f57215c79ce868870a6c410bd8fa18dd
parent28ce9d1c1751d70c869e3de3287b96d5e28fc845
signature Signed by PGP key B5690EEEBB952194

feat(auth): separate source control write permissions (#9787)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

59 files changed, 3628 insertions(+), 315 deletions(-)

apps/mobile/src/features/projects/AddProjectScreen.test.ts created+273
...@@ -0,0 +1,273 @@
1import * as NodeFS from "node:fs";
2import * as NodeFSP from "node:fs/promises";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts";
6import * as Cause from "effect/Cause";
7import { AsyncResult } from "effect/reactivity";
8import { isValidElement, type ReactNode } from "react";
9import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
10
11const state = vi.hoisted(() => ({
12 scopes: new Set<string>(),
13 otherScopes: new Set<string>(),
14 baseDirectory: "",
15 projects: [] as string[],
16 createRequests: [] as { environmentId: string; workspaceRoot: string }[],
17}));
18
19vi.mock("react", async (importOriginal) => ({
20 ...(await importOriginal<typeof import("react")>()),
21 useCallback: (callback: unknown) => callback,
22 useMemo: (factory: () => unknown) => factory(),
23 useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}],
24 useRef: (current: unknown) => ({ current }),
25 useEffect: () => {},
26}));
27vi.mock("../../components/MaterialListRow", () => ({ MaterialListRow: "MaterialListRow" }));
28vi.mock("../../components/MaterialButton", () => ({ MaterialButton: "MaterialButton" }));
29vi.mock("../../components/ScreenScrollView", () => ({ ScreenScrollView: "ScrollView" }));
30vi.mock("../settings/components/SettingsScreen", () => ({ SettingsScreen: "SettingsScreen" }));
31vi.mock("../../components/ThemedSwitch", () => ({ ThemedSwitch: "Switch" }));
32vi.mock("react-native", () => ({
33 Platform: { OS: "ios" },
34 ActivityIndicator: "ActivityIndicator",
35 Alert: { alert: () => {} },
36 Pressable: "Pressable",
37 ScrollView: "ScrollView",
38 View: "View",
39}));
40vi.mock("@react-navigation/native", () => ({
41 useNavigation: () => ({ dispatch: () => {} }),
42 CommonActions: { reset: (input: unknown) => input },
43 StackActions: {},
44}));
45vi.mock("react-native-safe-area-context", () => ({
46 useSafeAreaInsets: () => ({ bottom: 0 }),
47}));
48vi.mock("../../components/AppSymbol", () => ({ SymbolView: "SymbolView" }));
49vi.mock("../../components/AppText", () => ({ AppText: "Text", AppTextInput: "TextInput" }));
50vi.mock("../../components/EnvironmentMachineSymbol", () => ({
51 EnvironmentMachineSymbol: "EnvironmentMachineSymbol",
52}));
53vi.mock("../../components/ErrorBanner", () => ({ ErrorBanner: "ErrorBanner" }));
54vi.mock("../../components/SourceControlIcon", () => ({ SourceControlIcon: "SourceControlIcon" }));
55vi.mock("../../lib/uuid", () => ({ uuidv4: () => "project" }));
56vi.mock("../../state/session", () => ({
57 useEnvironmentScope: (environmentId: string, scope: string) =>
58 (environmentId === "environment" ? state.scopes : state.otherScopes).has(scope),
59 readEnvironmentScope: (environmentId: string, scope: string) =>
60 (environmentId === "environment" ? state.scopes : state.otherScopes).has(scope),
61}));
62vi.mock("../../state/entities", () => ({
63 useProjects: () => [],
64 useServerConfigs: () =>
65 new Map([
66 [
67 "environment",
68 {
69 environment: { platform: { os: "linux" }, capabilities: {} },
70 settings: { addProjectBaseDirectory: state.baseDirectory },
71 },
72 ],
73 [
74 "other-environment",
75 {
76 environment: { platform: { os: "linux" }, capabilities: {} },
77 settings: { addProjectBaseDirectory: state.baseDirectory },
78 },
79 ],
80 ]),
81}));
82vi.mock("../../state/use-remote-environment-registry", () => ({
83 useRemoteEnvironmentRuntime: () => ({ connectionState: "connected" }),
84 useRemoteConnectionStatus: () => ({
85 connectedEnvironments: [
86 { environmentId: "environment", connectionState: "connected" },
87 { environmentId: "other-environment", connectionState: "connected" },
88 ],
89 }),
90 useSavedRemoteConnections: () => ({
91 savedConnectionsById: {
92 connection: { environmentId: "environment", environmentLabel: "Environment" },
93 other: { environmentId: "other-environment", environmentLabel: "Other environment" },
94 },
95 }),
96}));
97vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
98vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => () => {} }));
99vi.mock("../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) }));
100vi.mock("../../state/presentation", () => ({
101 useEnvironmentPresentation: () => ({ isReady: true, presentation: null }),
102}));
103vi.mock("../../state/filesystem", () => ({ filesystemEnvironment: {} }));
104vi.mock("../../state/sourceControl", () => ({
105 sourceControlEnvironment: {
106 cloneRepository: async ({ input }: { input: { destinationPath: string } }) => {
107 NodeFS.mkdirSync(input.destinationPath);
108 return AsyncResult.success({ cwd: input.destinationPath });
109 },
110 },
111}));
112vi.mock("../../state/projects", () => ({
113 projectEnvironment: {
114 create: async ({
115 environmentId,
116 input,
117 }: {
118 environmentId: string;
119 input: { workspaceRoot: string };
120 }) => {
121 state.createRequests.push({ environmentId, workspaceRoot: input.workspaceRoot });
122 const scopes = environmentId === "environment" ? state.scopes : state.otherScopes;
123 if (!scopes.has(AuthOrchestrationOperateScope)) {
124 return AsyncResult.failure(Cause.fail(new Error("Project creation denied")));
125 }
126 state.projects.push(input.workspaceRoot);
127 return AsyncResult.success(undefined);
128 },
129 },
130}));
131
132import { AddProjectDestinationScreen, AddProjectLocalFolderScreen } from "./AddProjectScreen";
133
134function findAction(node: ReactNode, label: string): (() => unknown) | null {
135 if (Array.isArray(node)) {
136 for (const child of node) {
137 const action = findAction(child, label);
138 if (action) return action;
139 }
140 return null;
141 }
142 if (!isValidElement<{ label?: string; onPress?: () => unknown; children?: ReactNode }>(node)) {
143 return null;
144 }
145 if (node.props.label === label) return node.props.onPress ?? null;
146 return findAction(node.props.children, label);
147}
148
149function cloneAction() {
150 const action = findAction(
151 AddProjectDestinationScreen({
152 environmentId: "environment",
153 remoteUrl: "https://example.com/repo.git",
154 repositoryName: "repo",
155 }),
156 "Clone project",
157 );
158 if (!action) throw new Error("Clone action missing");
159 return action;
160}
161
162function localProjectAction(environmentId = "environment") {
163 const action = findAction(AddProjectLocalFolderScreen({ environmentId }), "Add project");
164 if (!action) throw new Error("Add project action missing");
165 return action;
166}
167
168describe("clone project permissions", () => {
169 beforeEach(async () => {
170 state.baseDirectory = await NodeFSP.mkdtemp(
171 NodePath.join(NodeOS.tmpdir(), "t3-clone-permissions-"),
172 );
173 state.scopes = new Set([AuthSourceControlWriteScope]);
174 state.otherScopes = new Set();
175 state.projects = [];
176 state.createRequests = [];
177 });
178
179 afterEach(async () => {
180 await NodeFSP.rm(state.baseDirectory, { recursive: true, force: true });
181 });
182
183 it("does not leave a clone on disk when project creation is denied", async () => {
184 await cloneAction()();
185
186 expect(NodeFS.existsSync(NodePath.join(state.baseDirectory, "repo"))).toBe(false);
187 expect(state.projects).toEqual([]);
188 });
189
190 it("clones and registers the project when both permissions are granted", async () => {
191 state.scopes.add(AuthOrchestrationOperateScope);
192 await cloneAction()();
193
194 const destination = NodePath.join(state.baseDirectory, "repo");
195 expect(NodeFS.existsSync(destination)).toBe(true);
196 expect(state.projects).toEqual([destination]);
197 });
198
199 it.each([AuthSourceControlWriteScope, AuthOrchestrationOperateScope])(
200 "rechecks %s before a retained clone action creates a directory",
201 async (scope) => {
202 state.scopes.add(AuthOrchestrationOperateScope);
203 const submit = cloneAction();
204 state.scopes.delete(scope);
205 await submit();
206
207 expect(NodeFS.existsSync(NodePath.join(state.baseDirectory, "repo"))).toBe(false);
208 expect(state.projects).toEqual([]);
209 },
210 );
211});
212
213describe("local project permissions", () => {
214 beforeEach(() => {
215 state.baseDirectory = "/workspace/project";
216 state.scopes = new Set();
217 state.otherScopes = new Set();
218 state.projects = [];
219 state.createRequests = [];
220 });
221
222 it.each([false, true])(
223 "does not dispatch project creation without the current grant (revoked: %s)",
224 async (revokeBeforeSubmit) => {
225 if (revokeBeforeSubmit) state.scopes.add(AuthOrchestrationOperateScope);
226 const submit = localProjectAction();
227 state.scopes.delete(AuthOrchestrationOperateScope);
228
229 await submit();
230
231 expect(state.createRequests).toEqual([]);
232 expect(state.projects).toEqual([]);
233 },
234 );
235
236 it("adds a typed local path with only task-operation permission", async () => {
237 state.scopes.add(AuthOrchestrationOperateScope);
238
239 await localProjectAction()();
240
241 expect(state.createRequests).toEqual([
242 { environmentId: "environment", workspaceRoot: state.baseDirectory },
243 ]);
244 expect(state.projects).toEqual([state.baseDirectory]);
245 });
246
247 it("uses a grant added while the local-folder form is open", async () => {
248 const submit = localProjectAction();
249 state.scopes.add(AuthOrchestrationOperateScope);
250
251 await submit();
252
253 expect(state.projects).toEqual([state.baseDirectory]);
254 });
255
256 it.each([false, true])(
257 "uses the selected environment's grant (allowed: %s)",
258 async (allowSelectedEnvironment) => {
259 (allowSelectedEnvironment ? state.otherScopes : state.scopes).add(
260 AuthOrchestrationOperateScope,
261 );
262
263 await localProjectAction("other-environment")();
264
265 expect(state.createRequests).toEqual(
266 allowSelectedEnvironment
267 ? [{ environmentId: "other-environment", workspaceRoot: state.baseDirectory }]
268 : [],
269 );
270 expect(state.projects).toEqual(allowSelectedEnvironment ? [state.baseDirectory] : []);
271 },
272 );
273});
apps/mobile/src/features/projects/AddProjectScreen.tsx+72-21
...@@ -39,6 +39,8 @@ import {...@@ -39,6 +39,8 @@ import {
39 isWindowsPlatform,39 isWindowsPlatform,
40} from "@t3tools/client-runtime/state/projects";40} from "@t3tools/client-runtime/state/projects";
41import {41import {
42 AuthOrchestrationOperateScope,
43 AuthSourceControlWriteScope,
42 CommandId,44 CommandId,
43 type EnvironmentId,45 type EnvironmentId,
44 type EnvironmentMachineKind,46 type EnvironmentMachineKind,
...@@ -59,6 +61,7 @@ import { useProjects, useServerConfigs, waitForProject } from "../../state/entit...@@ -59,6 +61,7 @@ import { useProjects, useServerConfigs, waitForProject } from "../../state/entit
59import { filesystemEnvironment } from "../../state/filesystem";61import { filesystemEnvironment } from "../../state/filesystem";
60import { projectEnvironment } from "../../state/projects";62import { projectEnvironment } from "../../state/projects";
61import { useEnvironmentQuery } from "../../state/query";63import { useEnvironmentQuery } from "../../state/query";
64import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
62import { sourceControlEnvironment } from "../../state/sourceControl";65import { sourceControlEnvironment } from "../../state/sourceControl";
63import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";66import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
64import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";67import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
...@@ -522,6 +525,15 @@ export function AddProjectSourceScreen() {...@@ -522,6 +525,15 @@ export function AddProjectSourceScreen() {
522 const navigation = useNavigation();525 const navigation = useNavigation();
523 const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } =526 const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } =
524 useSelectedEnvironment();527 useSelectedEnvironment();
528 const canWriteSourceControl = useEnvironmentScope(
529 selectedEnvironment?.environmentId ?? null,
530 AuthSourceControlWriteScope,
531 );
532 const canCreateProject = useEnvironmentScope(
533 selectedEnvironment?.environmentId ?? null,
534 AuthOrchestrationOperateScope,
535 );
536 const canCloneProject = canWriteSourceControl && canCreateProject;
525 const discoveryState = useEnvironmentQuery(537 const discoveryState = useEnvironmentQuery(
526 selectedEnvironment === null538 selectedEnvironment === null
527 ? null539 ? null
...@@ -610,7 +622,11 @@ export function AddProjectSourceScreen() {...@@ -610,7 +622,11 @@ export function AddProjectSourceScreen() {
610 ) : null}622 ) : null}
611 <ListRow623 <ListRow
612 title="Local folder"624 title="Local folder"
613 subtitle="Browse a folder on disk"625 subtitle={
626 canCreateProject
627 ? "Browse a folder on disk"
628 : "This connection cannot add projects."
629 }
614 icon={630 icon={
615 <SymbolView631 <SymbolView
616 name="folder.badge.plus"632 name="folder.badge.plus"
...@@ -620,6 +636,7 @@ export function AddProjectSourceScreen() {...@@ -620,6 +636,7 @@ export function AddProjectSourceScreen() {
620 />636 />
621 }637 }
622 isFirst={selectedEnvironment.newProjectsRoot === null}638 isFirst={selectedEnvironment.newProjectsRoot === null}
639 disabled={!canCreateProject}
623 onPress={() =>640 onPress={() =>
624 navigation.dispatch(641 navigation.dispatch(
625 StackActions.push("AddProjectLocal", {642 StackActions.push("AddProjectLocal", {
...@@ -634,11 +651,13 @@ export function AddProjectSourceScreen() {...@@ -634,11 +651,13 @@ export function AddProjectSourceScreen() {
634 key={candidate}651 key={candidate}
635 source={candidate}652 source={candidate}
636 selectedEnvironmentId={selectedEnvironment.environmentId}653 selectedEnvironmentId={selectedEnvironment.environmentId}
637 ready={readiness[candidate].ready}654 ready={canCloneProject && readiness[candidate].ready}
638 hint={655 hint={
639 readiness[candidate].ready656 !canCloneProject
640 ? addProjectRemoteSourcePathHint(candidate)657 ? "This connection cannot clone projects."
641 : (readiness[candidate].hint ?? "")658 : readiness[candidate].ready
659 ? addProjectRemoteSourcePathHint(candidate)
660 : (readiness[candidate].hint ?? "")
642 }661 }
643 isFirst={false}662 isFirst={false}
644 />663 />
...@@ -670,7 +689,17 @@ function useCreateProject(environment: EnvironmentOption | null) {...@@ -670,7 +689,17 @@ function useCreateProject(environment: EnvironmentOption | null) {
670689
671 return useCallback(690 return useCallback(
672 async (workspaceRoot: string) => {691 async (workspaceRoot: string) => {
673 if (!environment || !canCreateProjectInEnvironment(environment.connectionState)) return;692 if (
693 !environment ||
694 !canCreateProjectInEnvironment(environment.connectionState) ||
695 !readEnvironmentScope(environment.environmentId, AuthOrchestrationOperateScope)
696 ) {
697 Alert.alert(
698 "Project not added",
699 `This connection cannot add projects right now. Any existing files remain at ${workspaceRoot}.`,
700 );
701 return;
702 }
674703
675 const existing = findExistingAddProject({704 const existing = findExistingAddProject({
676 projects,705 projects,
...@@ -1159,6 +1188,10 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s...@@ -1159,6 +1188,10 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
11591188
1160export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) {1189export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) {
1161 const environment = useEnvironmentFromParam(props.environmentId);1190 const environment = useEnvironmentFromParam(props.environmentId);
1191 const canCreateProject = useEnvironmentScope(
1192 environment?.environmentId ?? null,
1193 AuthOrchestrationOperateScope,
1194 );
1162 const createProject = useCreateProject(environment);1195 const createProject = useCreateProject(environment);
1163 const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } =1196 const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } =
1164 useBrowsePathInput(environment);1197 useBrowsePathInput(environment);
...@@ -1191,15 +1224,14 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st...@@ -1191,15 +1224,14 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st
1191 {error ? <ErrorBanner message={error} /> : null}1224 {error ? <ErrorBanner message={error} /> : null}
1192 {environment ? (1225 {environment ? (
1193 <>1226 <>
1194 <ProjectPathInput1227 {!canCreateProject ? (
1195 value={pathInput}1228 <ErrorBanner message="This connection cannot add projects." />
1196 onChangeText={setPathInput}1229 ) : null}
1197 onSubmit={() => void submitPath()}1230 <ProjectPathInput value={pathInput} onChangeText={setPathInput} onSubmit={submitPath} />
1198 />
1199 <PrimaryActionButton1231 <PrimaryActionButton
1200 label="Add project"1232 label="Add project"
1201 disabled={isBrowseNavigating || isSubmitting}1233 disabled={!canCreateProject || isBrowseNavigating || isSubmitting}
1202 onPress={() => void submitPath()}1234 onPress={submitPath}
1203 loading={isSubmitting}1235 loading={isSubmitting}
1204 />1236 />
1205 <FolderBrowser1237 <FolderBrowser
...@@ -1230,6 +1262,15 @@ export function AddProjectDestinationScreen(props: {...@@ -1230,6 +1262,15 @@ export function AddProjectDestinationScreen(props: {
1230 });1262 });
1231 const navigation = useNavigation();1263 const navigation = useNavigation();
1232 const environment = useEnvironmentFromParam(props.environmentId);1264 const environment = useEnvironmentFromParam(props.environmentId);
1265 const canWriteSourceControl = useEnvironmentScope(
1266 environment?.environmentId ?? null,
1267 AuthSourceControlWriteScope,
1268 );
1269 const canCreateProject = useEnvironmentScope(
1270 environment?.environmentId ?? null,
1271 AuthOrchestrationOperateScope,
1272 );
1273 const canCloneProject = canWriteSourceControl && canCreateProject;
1233 const createProject = useCreateProject(environment);1274 const createProject = useCreateProject(environment);
1234 const remoteUrl = stringParam(props.remoteUrl);1275 const remoteUrl = stringParam(props.remoteUrl);
1235 const repositoryTitle = stringParam(props.repositoryTitle);1276 const repositoryTitle = stringParam(props.repositoryTitle);
...@@ -1246,7 +1287,16 @@ export function AddProjectDestinationScreen(props: {...@@ -1246,7 +1287,16 @@ export function AddProjectDestinationScreen(props: {
1246 const [error, setError] = useState<string | null>(null);1287 const [error, setError] = useState<string | null>(null);
12471288
1248 const submitPath = useCallback(async () => {1289 const submitPath = useCallback(async () => {
1249 if (!environment || !remoteUrl || isBrowseNavigating || isSubmitting) return;1290 if (
1291 !environment ||
1292 !readEnvironmentScope(environment.environmentId, AuthSourceControlWriteScope) ||
1293 !readEnvironmentScope(environment.environmentId, AuthOrchestrationOperateScope) ||
1294 !remoteUrl ||
1295 isBrowseNavigating ||
1296 isSubmitting
1297 ) {
1298 return;
1299 }
1250 setError(null);1300 setError(null);
1251 const resolved = resolveAddProjectPath({1301 const resolved = resolveAddProjectPath({
1252 rawPath: pathInput,1302 rawPath: pathInput,
...@@ -1342,17 +1392,18 @@ export function AddProjectDestinationScreen(props: {...@@ -1342,17 +1392,18 @@ export function AddProjectDestinationScreen(props: {
1342 ) : null}1392 ) : null}
1343 {environment ? (1393 {environment ? (
1344 <>1394 <>
1345 <ProjectPathInput1395 <ProjectPathInput value={pathInput} onChangeText={setPathInput} onSubmit={submitPath} />
1346 value={pathInput}
1347 onChangeText={setPathInput}
1348 onSubmit={() => void submitPath()}
1349 />
1350 <PrimaryActionButton1396 <PrimaryActionButton
1351 label="Clone project"1397 label="Clone project"
1352 disabled={isBrowseNavigating || isSubmitting || !remoteUrl}1398 disabled={!canCloneProject || isBrowseNavigating || isSubmitting || !remoteUrl}
1353 onPress={() => void submitPath()}1399 onPress={submitPath}
1354 loading={isSubmitting}1400 loading={isSubmitting}
1355 />1401 />
1402 {!canCloneProject ? (
1403 <Text className="text-sm text-foreground-muted">
1404 This connection cannot clone projects.
1405 </Text>
1406 ) : null}
1356 <FolderBrowser1407 <FolderBrowser
1357 environment={environment}1408 environment={environment}
1358 navigateToBrowsePath={navigateToBrowsePath}1409 navigateToBrowsePath={navigateToBrowsePath}
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+14-2
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1import { MaterialListRow } from "../../components/MaterialListRow";1import { MaterialListRow } from "../../components/MaterialListRow";
2import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";2import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
3import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation";
3import type { VcsRef } from "@t3tools/client-runtime/state/vcs";4import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
4import { resolveEnvironmentMachineKind } from "@t3tools/contracts";5import { AuthSourceControlWriteScope, resolveEnvironmentMachineKind } from "@t3tools/contracts";
5import { LegendList } from "@legendapp/list/react-native";6import { LegendList } from "@legendapp/list/react-native";
6import {7import {
7 isAtomCommandInterrupted,8 isAtomCommandInterrupted,
...@@ -30,6 +31,7 @@ import { ThemedSwitch } from "../../components/ThemedSwitch";...@@ -30,6 +31,7 @@ import { ThemedSwitch } from "../../components/ThemedSwitch";
30import { cn } from "../../lib/cn";31import { cn } from "../../lib/cn";
31import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";32import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
32import { useServerConfigs } from "../../state/entities";33import { useServerConfigs } from "../../state/entities";
34import { useEnvironmentScope } from "../../state/session";
33import { useAtomCommand } from "../../state/use-atom-command";35import { useAtomCommand } from "../../state/use-atom-command";
34import { vcsEnvironment } from "../../state/vcs";36import { vcsEnvironment } from "../../state/vcs";
35import {37import {
...@@ -264,6 +266,10 @@ export function NewTaskEnvironmentPickerRouteScreen() {...@@ -264,6 +266,10 @@ export function NewTaskEnvironmentPickerRouteScreen() {
264266
265export function NewTaskBranchPickerRouteScreen() {267export function NewTaskBranchPickerRouteScreen() {
266 const flow = useNewTaskFlow();268 const flow = useNewTaskFlow();
269 const canWriteSourceControl = useEnvironmentScope(
270 flow.selectedProject?.environmentId ?? null,
271 AuthSourceControlWriteScope,
272 );
267 const navigation = useNavigation();273 const navigation = useNavigation();
268 const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });274 const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
269 const [switchingBranchName, setSwitchingBranchName] = useState<string | null>(null);275 const [switchingBranchName, setSwitchingBranchName] = useState<string | null>(null);
...@@ -291,7 +297,12 @@ export function NewTaskBranchPickerRouteScreen() {...@@ -291,7 +297,12 @@ export function NewTaskBranchPickerRouteScreen() {
291297
292 const selectBranch = useCallback(298 const selectBranch = useCallback(
293 async (branch: VcsRef) => {299 async (branch: VcsRef) => {
294 if (selectingBranchNameRef.current !== null) {300 const needsCheckout = shouldCheckoutNewTaskBranch({
301 branchIsCurrent: branch.current,
302 branchWorktreePath: branch.worktreePath,
303 workspaceMode: flow.workspaceMode,
304 });
305 if (selectingBranchNameRef.current !== null || (needsCheckout && !canWriteSourceControl)) {
295 return;306 return;
296 }307 }
297 selectingBranchNameRef.current = branch.name;308 selectingBranchNameRef.current = branch.name;
...@@ -336,6 +347,7 @@ export function NewTaskBranchPickerRouteScreen() {...@@ -336,6 +347,7 @@ export function NewTaskBranchPickerRouteScreen() {
336 }347 }
337 },348 },
338 [349 [
350 canWriteSourceControl,
339 flow.selectBranch,351 flow.selectBranch,
340 flow.selectedProject,352 flow.selectedProject,
341 flow.setBranchQuery,353 flow.setBranchQuery,
apps/mobile/src/features/threads/ThreadGitControls.tsx+35-13
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { createNativeHeaderMenu } from "../../components/nativeHeaderMenu.ios";1import { createNativeHeaderMenu } from "../../components/nativeHeaderMenu.ios";
2import type { ScreenHeaderMenu } from "../../components/ScreenHeader.types";2import type { ScreenHeaderMenu } from "../../components/ScreenHeader.types";
3import {3import {
4 AuthSourceControlWriteScope,
4 EnvironmentId,5 EnvironmentId,
5 type GitRunStackedActionResult,6 type GitRunStackedActionResult,
6 type ProjectScript,7 type ProjectScript,
...@@ -17,6 +18,7 @@ import { NativeHeaderToolbar } from "../../native/StackHeader";...@@ -17,6 +18,7 @@ import { NativeHeaderToolbar } from "../../native/StackHeader";
17import { useCallback, useMemo } from "react";18import { useCallback, useMemo } from "react";
18import { Alert } from "react-native";19import { Alert } from "react-native";
19import { tryOpenExternalUrl } from "../../lib/openExternalUrl";20import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
21import { useEnvironmentScope } from "../../state/session";
20import {22import {
21 basename,23 basename,
22 getTerminalStatusLabel,24 getTerminalStatusLabel,
...@@ -113,6 +115,10 @@ type ThreadGitControlsProps = ThreadGitMenuProps & {...@@ -113,6 +115,10 @@ type ThreadGitControlsProps = ThreadGitMenuProps & {
113function useThreadGitControlModel(props: ThreadGitMenuProps) {115function useThreadGitControlModel(props: ThreadGitMenuProps) {
114 const navigation = useNavigation();116 const navigation = useNavigation();
115 const environmentId = props.environmentId;117 const environmentId = props.environmentId;
118 const canWriteSourceControl = useEnvironmentScope(
119 environmentId ? EnvironmentId.make(String(environmentId)) : null,
120 AuthSourceControlWriteScope,
121 );
116 const threadId = props.threadId;122 const threadId = props.threadId;
117 const { gitStatus, gitOperationLabel, onPull, onRunAction } = props;123 const { gitStatus, gitOperationLabel, onPull, onRunAction } = props;
118124
...@@ -122,18 +128,24 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) {...@@ -122,18 +128,24 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) {
122 const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;128 const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;
123 const isDefaultRef = gitStatus?.isDefaultRef ?? false;129 const isDefaultRef = gitStatus?.isDefaultRef ?? false;
124130
125 const quickAction = useMemo(131 const quickAction = useMemo(() => {
126 () =>132 if (!isRepo) {
127 isRepo133 return {
128 ? resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote)134 label: "Git unavailable",
129 : {135 disabled: true,
130 label: "Git unavailable",136 kind: "show_hint" as const,
131 disabled: true,137 hint: "This workspace is not a git repository.",
132 kind: "show_hint" as const,138 };
133 hint: "This workspace is not a git repository.",139 }
134 },140 const action = resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote);
135 [busy, gitStatus, hasPrimaryRemote, isDefaultRef, isRepo],141 return !canWriteSourceControl && (action.kind === "run_pull" || action.kind === "run_action")
136 );142 ? {
143 ...action,
144 disabled: true,
145 hint: "This connection cannot change source control.",
146 }
147 : action;
148 }, [busy, canWriteSourceControl, gitStatus, hasPrimaryRemote, isDefaultRef, isRepo]);
137149
138 const quickActionHint = quickAction.disabled150 const quickActionHint = quickAction.disabled
139 ? (quickAction.hint ?? "This action is unavailable.")151 ? (quickAction.hint ?? "This action is unavailable.")
...@@ -163,6 +175,7 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) {...@@ -163,6 +175,7 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) {
163175
164 const runActionWithPrompt = useCallback(176 const runActionWithPrompt = useCallback(
165 async (input: GitActionRequestInput) => {177 async (input: GitActionRequestInput) => {
178 if (!canWriteSourceControl) return;
166 const confirmableAction =179 const confirmableAction =
167 input.action === "push" ||180 input.action === "push" ||
168 input.action === "create_pr" ||181 input.action === "create_pr" ||
...@@ -191,10 +204,19 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) {...@@ -191,10 +204,19 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) {
191204
192 await onRunAction(input);205 await onRunAction(input);
193 },206 },
194 [environmentId, gitStatus, isDefaultRef, onRunAction, navigation, threadId],207 [
208 canWriteSourceControl,
209 environmentId,
210 gitStatus,
211 isDefaultRef,
212 onRunAction,
213 navigation,
214 threadId,
215 ],
195 );216 );
196217
197 const runQuickAction = useCallback(async () => {218 const runQuickAction = useCallback(async () => {
219 if (quickAction.disabled) return;
198 if (quickAction.kind === "open_pr") {220 if (quickAction.kind === "open_pr") {
199 await openExistingPr();221 await openExistingPr();
200 return;222 return;
apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx created+172
...@@ -0,0 +1,172 @@
1import { isValidElement, type ReactNode } from "react";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4const state = vi.hoisted(() => ({
5 fields: [] as string[],
6 fieldIndex: 0,
7 canChangeThreadBranch: true,
8 navigations: 0,
9 result: Promise.resolve(null) as Promise<unknown>,
10}));
11
12vi.mock("react", async (importOriginal) => ({
13 ...(await importOriginal<typeof import("react")>()),
14 useState: (initial: string) => {
15 const index = state.fieldIndex++;
16 state.fields[index] ??= initial;
17 return [state.fields[index], (value: string) => (state.fields[index] = value)];
18 },
19}));
20vi.mock("react-native", () => ({
21 Platform: { OS: "ios" },
22 useWindowDimensions: () => ({ height: 800, width: 400 }),
23 Pressable: "Pressable",
24 ScrollView: "ScrollView",
25 View: "View",
26}));
27vi.mock("@react-navigation/native", () => ({
28 useNavigation: () => ({ goBack: () => state.navigations++ }),
29}));
30vi.mock("react-native-safe-area-context", () => ({
31 useSafeAreaInsets: () => ({ bottom: 0 }),
32}));
33vi.mock("../../../components/AndroidScreenHeader", () => ({
34 AndroidSheetHeader: "AndroidSheetHeader",
35}));
36vi.mock("../../../components/AppText", () => ({ AppText: "Text", AppTextInput: "TextInput" }));
37vi.mock("./gitSheetComponents", () => ({ SheetActionButton: "SheetActionButton" }));
38vi.mock("../../../state/query", () => ({
39 useEnvironmentQuery: () => ({ data: { refName: "main" } }),
40}));
41vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { status: () => null } }));
42vi.mock("../../../state/use-thread-selection", () => ({
43 useThreadSelection: () => ({ selectedThread: { environmentId: "environment", branch: "main" } }),
44}));
45vi.mock("../../../state/use-selected-thread-worktree", () => ({
46 useSelectedThreadWorktree: () => ({
47 selectedThreadCwd: "/repo",
48 selectedThreadWorktreePath: null,
49 }),
50}));
51vi.mock("../../../state/use-selected-thread-git-state", () => ({
52 useSelectedThreadGitState: () => ({
53 selectedThreadBranches: [{ name: "main", current: true, isDefault: true, worktreePath: null }],
54 selectedThreadBranchesLoading: false,
55 gitOperationLabel: null,
56 }),
57}));
58vi.mock("../../../state/use-selected-thread-git-actions", () => ({
59 useSelectedThreadGitActions: () => ({
60 canChangeThreadBranch: state.canChangeThreadBranch,
61 onCreateSelectedThreadBranch: () => state.result,
62 onCreateSelectedThreadWorktree: () => state.result,
63 onCheckoutSelectedThreadBranch: () => state.result,
64 }),
65}));
66
67import { GitBranchesSheet } from "./GitBranchesSheet";
68
69type ControlProps = {
70 label?: string;
71 placeholder?: string;
72 onPress?: () => unknown;
73 onChangeText?: (value: string) => void;
74 value?: string;
75 children?: ReactNode;
76};
77
78function findControl(node: ReactNode, label: string): ControlProps | null {
79 if (Array.isArray(node)) {
80 for (const child of node) {
81 const control = findControl(child, label);
82 if (control) return control;
83 }
84 return null;
85 }
86 if (!isValidElement<ControlProps>(node)) return null;
87 if (
88 node.props.label === label ||
89 node.props.placeholder === label ||
90 (label === "checkout" && node.type === "Pressable")
91 ) {
92 return node.props;
93 }
94 return findControl(node.props.children, label);
95}
96
97function control(label: string) {
98 state.fieldIndex = 0;
99 const found = findControl(
100 GitBranchesSheet({ route: { params: { environmentId: "environment", threadId: "thread" } } }),
101 label,
102 );
103 if (!found) throw new Error(`Missing sheet control: ${label}`);
104 return found;
105}
106
107function deferred<T>() {
108 let resolve!: (value: T) => void;
109 const promise = new Promise<T>((complete) => {
110 resolve = complete;
111 });
112 return { promise, resolve };
113}
114
115describe("branch sheet operation completion", () => {
116 beforeEach(() => {
117 state.fields = [];
118 state.fieldIndex = 0;
119 state.canChangeThreadBranch = true;
120 state.navigations = 0;
121 control("feature/mobile-polish").onChangeText?.("feature/branch");
122 control("main").onChangeText?.("release");
123 control("feature/mobile-thread").onChangeText?.("feature/worktree");
124 });
125
126 it.each(["Create & checkout", "Create worktree", "checkout"])(
127 "keeps the sheet and its input when %s cannot complete",
128 async (operation) => {
129 const result = deferred<null>();
130 state.result = result.promise;
131 const press = control(operation).onPress;
132 if (!press) throw new Error("Missing operation handler");
133 state.canChangeThreadBranch = false;
134 const completion = press();
135 result.resolve(null);
136 await result.promise;
137 await completion;
138
139 expect(state.navigations).toBe(0);
140 expect(control("feature/mobile-polish").value).toBe("feature/branch");
141 expect(control("main").value).toBe("release");
142 expect(control("feature/mobile-thread").value).toBe("feature/worktree");
143 },
144 );
145
146 it.each(["Create & checkout", "Create worktree", "checkout"])(
147 "dismisses after accepted %s completion even if the source grant changes",
148 async (operation) => {
149 const result = deferred<unknown>();
150 state.result = result.promise;
151 const press = control(operation).onPress;
152 if (!press) throw new Error("Missing operation handler");
153 const completion = press();
154 state.canChangeThreadBranch = false;
155 result.resolve(
156 operation === "Create worktree"
157 ? { worktree: { path: "/repo-worktree", refName: "feature/worktree" } }
158 : { refName: operation === "checkout" ? "main" : "feature/branch" },
159 );
160 await result.promise;
161 await completion;
162
163 expect(state.navigations).toBe(1);
164 expect(control("feature/mobile-polish").value).toBe(
165 operation === "Create & checkout" ? "" : "feature/branch",
166 );
167 expect(control("feature/mobile-thread").value).toBe(
168 operation === "Create worktree" ? "" : "feature/worktree",
169 );
170 },
171 );
172});
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+10-5
...@@ -30,6 +30,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {...@@ -30,6 +30,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {
30 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();30 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
31 const gitState = useSelectedThreadGitState();31 const gitState = useSelectedThreadGitState();
32 const gitActions = useSelectedThreadGitActions();32 const gitActions = useSelectedThreadGitActions();
33 const { canChangeThreadBranch } = gitActions;
3334
34 const gitStatus = useEnvironmentQuery(35 const gitStatus = useEnvironmentQuery(
35 selectedThread !== null && selectedThreadCwd !== null36 selectedThread !== null && selectedThreadCwd !== null
...@@ -109,11 +110,12 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {...@@ -109,11 +110,12 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {
109 icon="plus"110 icon="plus"
110 label="Create & checkout"111 label="Create & checkout"
111 tone="primary"112 tone="primary"
112 disabled={busy || newBranchName.trim().length === 0}113 disabled={!canChangeThreadBranch || busy || newBranchName.trim().length === 0}
113 onPress={() => {114 onPress={() => {
114 const branch = sanitizeFeatureBranchName(newBranchName.trim());115 const branch = sanitizeFeatureBranchName(newBranchName.trim());
115 if (branch.length === 0) return;116 if (branch.length === 0) return;
116 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {117 void gitActions.onCreateSelectedThreadBranch(branch).then((result) => {
118 if (result === null) return;
117 setNewBranchName("");119 setNewBranchName("");
118 navigation.goBack();120 navigation.goBack();
119 });121 });
...@@ -150,6 +152,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {...@@ -150,6 +152,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {
150 label="Create worktree"152 label="Create worktree"
151 tone="primary"153 tone="primary"
152 disabled={154 disabled={
155 !canChangeThreadBranch ||
153 busy ||156 busy ||
154 worktreeBaseBranch.trim().length === 0 ||157 worktreeBaseBranch.trim().length === 0 ||
155 worktreeBranchName.trim().length === 0158 worktreeBranchName.trim().length === 0
...@@ -160,7 +163,8 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {...@@ -160,7 +163,8 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {
160 if (baseBranch.length === 0 || newBranch.length === 0) return;163 if (baseBranch.length === 0 || newBranch.length === 0) return;
161 void gitActions164 void gitActions
162 .onCreateSelectedThreadWorktree({ baseBranch, newBranch })165 .onCreateSelectedThreadWorktree({ baseBranch, newBranch })
163 .then(() => {166 .then((result) => {
167 if (result === null) return;
164 setWorktreeBranchName("");168 setWorktreeBranchName("");
165 navigation.goBack();169 navigation.goBack();
166 });170 });
...@@ -203,9 +207,10 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {...@@ -203,9 +207,10 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {
203 )}207 )}
204 accessibilityRole="button"208 accessibilityRole="button"
205 accessibilityState={{ selected: branch.current, disabled: busy || disabled }}209 accessibilityState={{ selected: branch.current, disabled: busy || disabled }}
206 disabled={busy || disabled}210 disabled={!canChangeThreadBranch || busy || disabled}
207 onPress={() => {211 onPress={() => {
208 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {212 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then((result) => {
213 if (result === null) return;
209 navigation.goBack();214 navigation.goBack();
210 });215 });
211 }}216 }}
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+13-3
...@@ -30,6 +30,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {...@@ -30,6 +30,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
30 const { selectedThreadCwd } = useSelectedThreadWorktree();30 const { selectedThreadCwd } = useSelectedThreadWorktree();
31 const gitState = useSelectedThreadGitState();31 const gitState = useSelectedThreadGitState();
32 const gitActions = useSelectedThreadGitActions();32 const gitActions = useSelectedThreadGitActions();
33 const { canWriteSourceControl, canChangeThreadBranch } = gitActions;
3334
34 const gitStatus = useEnvironmentQuery(35 const gitStatus = useEnvironmentQuery(
35 selectedThread !== null && selectedThreadCwd !== null36 selectedThread !== null && selectedThreadCwd !== null
...@@ -57,6 +58,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {...@@ -57,6 +58,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
5758
58 const runCommitAction = useCallback(59 const runCommitAction = useCallback(
59 async (featureBranch: boolean) => {60 async (featureBranch: boolean) => {
61 if (!canWriteSourceControl || (featureBranch && !canChangeThreadBranch)) return;
60 const commitMessage = dialogCommitMessage.trim();62 const commitMessage = dialogCommitMessage.trim();
61 navigation.goBack();63 navigation.goBack();
62 await gitActions.onRunSelectedThreadGitAction({64 await gitActions.onRunSelectedThreadGitAction({
...@@ -66,7 +68,15 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {...@@ -66,7 +68,15 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
66 ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),68 ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),
67 });69 });
68 },70 },
69 [allSelected, dialogCommitMessage, gitActions, navigation, selectedFiles],71 [
72 allSelected,
73 canWriteSourceControl,
74 canChangeThreadBranch,
75 dialogCommitMessage,
76 gitActions,
77 navigation,
78 selectedFiles,
79 ],
70 );80 );
7181
72 return (82 return (
...@@ -274,7 +284,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {...@@ -274,7 +284,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
274 <SheetActionButton284 <SheetActionButton
275 icon="arrow.branch"285 icon="arrow.branch"
276 label="Commit on new branch"286 label="Commit on new branch"
277 disabled={noneSelected || busy}287 disabled={!canChangeThreadBranch || noneSelected || busy}
278 onPress={() => void runCommitAction(true)}288 onPress={() => void runCommitAction(true)}
279 />289 />
280 </View>290 </View>
...@@ -283,7 +293,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {...@@ -283,7 +293,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
283 icon="checkmark.circle"293 icon="checkmark.circle"
284 label="Commit"294 label="Commit"
285 tone="primary"295 tone="primary"
286 disabled={noneSelected || busy}296 disabled={!canWriteSourceControl || noneSelected || busy}
287 onPress={() => void runCommitAction(false)}297 onPress={() => void runCommitAction(false)}
288 />298 />
289 </View>299 </View>
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+17-4
...@@ -32,6 +32,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {...@@ -32,6 +32,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {
32 const { height: windowHeight } = useWindowDimensions();32 const { height: windowHeight } = useWindowDimensions();
33 const gitState = useSelectedThreadGitState();33 const gitState = useSelectedThreadGitState();
34 const gitActions = useSelectedThreadGitActions();34 const gitActions = useSelectedThreadGitActions();
35 const { canWriteSourceControl, canChangeThreadBranch } = gitActions;
3536
36 const params = props.route.params;37 const params = props.route.params;
3738
...@@ -59,17 +60,25 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {...@@ -59,17 +60,25 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {
59 );60 );
6061
61 const continuePendingAction = useCallback(async () => {62 const continuePendingAction = useCallback(async () => {
62 if (!confirmAction) return;63 if (!canWriteSourceControl || !confirmAction) return;
63 navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId }));64 navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId }));
64 await gitActions.onRunSelectedThreadGitAction({65 await gitActions.onRunSelectedThreadGitAction({
65 action: confirmAction,66 action: confirmAction,
66 ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}),67 ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}),
67 ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}),68 ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}),
68 });69 });
69 }, [confirmAction, environmentId, gitActions, params, navigation, threadId]);70 }, [
71 canWriteSourceControl,
72 confirmAction,
73 environmentId,
74 gitActions,
75 params,
76 navigation,
77 threadId,
78 ]);
7079
71 const movePendingActionToFeatureBranch = useCallback(async () => {80 const movePendingActionToFeatureBranch = useCallback(async () => {
72 if (!confirmAction) return;81 if (!canChangeThreadBranch || !confirmAction) return;
73 navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId }));82 navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId }));
7483
75 if (includesCommit) {84 if (includesCommit) {
...@@ -91,9 +100,11 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {...@@ -91,9 +100,11 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {
91 branch.isRemote ? Result.failVoid : Result.succeed(branch.name),100 branch.isRemote ? Result.failVoid : Result.succeed(branch.name),
92 ),101 ),
93 );102 );
94 await gitActions.onCreateSelectedThreadBranch(newBranchName);103 const created = await gitActions.onCreateSelectedThreadBranch(newBranchName);
104 if (created === null) return;
95 await gitActions.onRunSelectedThreadGitAction({ action: confirmAction });105 await gitActions.onRunSelectedThreadGitAction({ action: confirmAction });
96 }, [106 }, [
107 canChangeThreadBranch,
97 confirmAction,108 confirmAction,
98 gitActions,109 gitActions,
99 gitState.selectedThreadBranches,110 gitState.selectedThreadBranches,
...@@ -164,11 +175,13 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {...@@ -164,11 +175,13 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {
164 <SheetActionButton175 <SheetActionButton
165 icon="arrow.right.circle"176 icon="arrow.right.circle"
166 label={copy?.continueLabel ?? "Continue"}177 label={copy?.continueLabel ?? "Continue"}
178 disabled={!canWriteSourceControl}
167 onPress={() => void continuePendingAction()}179 onPress={() => void continuePendingAction()}
168 />180 />
169 <SheetActionButton181 <SheetActionButton
170 icon="arrow.branch"182 icon="arrow.branch"
171 label="Feature branch & continue"183 label="Feature branch & continue"
184 disabled={!canChangeThreadBranch}
172 tone="primary"185 tone="primary"
173 onPress={() => void movePendingActionToFeatureBranch()}186 onPress={() => void movePendingActionToFeatureBranch()}
174 />187 />
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+46-13
...@@ -87,6 +87,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {...@@ -87,6 +87,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
87 );87 );
88 const gitState = useSelectedThreadGitState();88 const gitState = useSelectedThreadGitState();
89 const gitActions = useSelectedThreadGitActions();89 const gitActions = useSelectedThreadGitActions();
90 const { canWriteSourceControl, canChangeThreadBranch } = gitActions;
90 const theme = useUniwindTheme();91 const theme = useUniwindTheme();
91 const foregroundColor = theme["--color-foreground"];92 const foregroundColor = theme["--color-foreground"];
92 const sheetColor = theme["--color-sheet"];93 const sheetColor = theme["--color-sheet"];
...@@ -117,15 +118,21 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {...@@ -117,15 +118,21 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
117 const sheetMenuItems = useMemo(118 const sheetMenuItems = useMemo(
118 () =>119 () =>
119 menuItems.map((item) => ({120 menuItems.map((item) => ({
120 item,121 item: {
121 disabledReason: getGitActionDisabledReason({122 ...item,
122 item,123 disabled: item.disabled || (!canWriteSourceControl && item.kind !== "open_pr"),
123 gitStatus: gitStatus.data,124 },
124 isBusy: busy,125 disabledReason:
125 hasOriginRemote: hasPrimaryRemote,126 !canWriteSourceControl && item.kind !== "open_pr"
126 }),127 ? "This connection cannot change source control."
128 : getGitActionDisabledReason({
129 item,
130 gitStatus: gitStatus.data,
131 isBusy: busy,
132 hasOriginRemote: hasPrimaryRemote,
133 }),
127 })),134 })),
128 [busy, gitStatus.data, hasPrimaryRemote, menuItems],135 [busy, canWriteSourceControl, gitStatus.data, hasPrimaryRemote, menuItems],
129 );136 );
130137
131 useEffect(() => {138 useEffect(() => {
...@@ -145,6 +152,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {...@@ -145,6 +152,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
145152
146 const runActionWithPrompt = useCallback(153 const runActionWithPrompt = useCallback(
147 async (input: GitActionRequestInput) => {154 async (input: GitActionRequestInput) => {
155 if (!canWriteSourceControl) return;
148 const confirmableAction =156 const confirmableAction =
149 input.action === "push" ||157 input.action === "push" ||
150 input.action === "create_pr" ||158 input.action === "create_pr" ||
...@@ -176,7 +184,16 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {...@@ -176,7 +184,16 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
176 }184 }
177 await gitActions.onRunSelectedThreadGitAction(input);185 await gitActions.onRunSelectedThreadGitAction(input);
178 },186 },
179 [environmentId, gitActions, gitStatus.data, isDefaultRef, isInspector, navigation, threadId],187 [
188 canWriteSourceControl,
189 environmentId,
190 gitActions,
191 gitStatus.data,
192 isDefaultRef,
193 isInspector,
194 navigation,
195 threadId,
196 ],
180 );197 );
181198
182 const onPressMenuItem = useCallback(199 const onPressMenuItem = useCallback(
...@@ -186,6 +203,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {...@@ -186,6 +203,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
186 await openExistingPr();203 await openExistingPr();
187 return;204 return;
188 }205 }
206 if (!canWriteSourceControl) return;
189 if (item.dialogAction === "commit") {207 if (item.dialogAction === "commit") {
190 navigation.navigate("GitCommit", {208 navigation.navigate("GitCommit", {
191 environmentId: String(environmentId),209 environmentId: String(environmentId),
...@@ -201,7 +219,14 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {...@@ -201,7 +219,14 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
201 await runActionWithPrompt({ action: "create_pr" });219 await runActionWithPrompt({ action: "create_pr" });
202 }220 }
203 },221 },
204 [environmentId, openExistingPr, navigation, runActionWithPrompt, threadId],222 [
223 canWriteSourceControl,
224 environmentId,
225 openExistingPr,
226 navigation,
227 runActionWithPrompt,
228 threadId,
229 ],
205 );230 );
206231
207 // Status facts live on the relevant rows instead of crowding the header232 // Status facts live on the relevant rows instead of crowding the header
...@@ -284,8 +309,12 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {...@@ -284,8 +309,12 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
284 <SheetListRow309 <SheetListRow
285 icon="arrow.down.circle"310 icon="arrow.down.circle"
286 title="Pull latest"311 title="Pull latest"
287 subtitle={`${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream`}312 subtitle={
288 disabled={busy || !isRepo}313 canWriteSourceControl
314 ? `${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream`
315 : "This connection cannot change source control."
316 }
317 disabled={!canWriteSourceControl || busy || !isRepo}
289 onPress={() => void gitActions.onPullSelectedThreadBranch()}318 onPress={() => void gitActions.onPullSelectedThreadBranch()}
290 />319 />
291 </>320 </>
...@@ -309,7 +338,11 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {...@@ -309,7 +338,11 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
309 <SheetListRow338 <SheetListRow
310 icon="point.topleft.down.curvedto.point.bottomright.up"339 icon="point.topleft.down.curvedto.point.bottomright.up"
311 title="Branches & worktrees"340 title="Branches & worktrees"
312 subtitle="Switch branch, create branch, or move to a worktree"341 subtitle={
342 canChangeThreadBranch
343 ? "Switch branch, create branch, or move to a worktree"
344 : "View branches and worktrees"
345 }
313 disabled={busy || !isRepo}346 disabled={busy || !isRepo}
314 onPress={() =>347 onPress={() =>
315 navigation.navigate("GitBranches", {348 navigation.navigate("GitBranches", {
apps/mobile/src/state/use-selected-thread-git-actions.test.ts created+283
...@@ -0,0 +1,283 @@
1import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import { AsyncResult } from "effect/reactivity";
4import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6const state = vi.hoisted(() => ({
7 scopes: new Set<string>(),
8 primaryScopes: new Set<string>(),
9 branch: "main",
10 worktrees: [] as string[],
11 thread: {
12 id: "thread",
13 environmentId: "environment",
14 branch: "main",
15 worktreePath: null as string | null,
16 },
17 commits: 0,
18 pushes: 0,
19 metadataRequests: [] as { environmentId: string; branch: string }[],
20 results: [] as { type: string; description?: string }[],
21 afterGitAction: undefined as (() => void) | undefined,
22 statusRefreshes: 0,
23}));
24
25vi.mock("react", () => ({
26 useCallback: (callback: unknown) => callback,
27 useMemo: (factory: () => unknown) => factory(),
28 useEffect: () => {},
29}));
30vi.mock("./session", () => ({
31 useEnvironmentScope: (environmentId: unknown, scope: string) =>
32 (environmentId === state.thread.environmentId ? state.scopes : state.primaryScopes).has(scope),
33 readEnvironmentScope: (environmentId: unknown, scope: string) =>
34 (environmentId === state.thread.environmentId ? state.scopes : state.primaryScopes).has(scope),
35}));
36vi.mock("./use-thread-selection", () => ({
37 useThreadSelection: () => ({
38 selectedThread: state.thread,
39 selectedThreadProject: { workspaceRoot: "/repo" },
40 }),
41}));
42vi.mock("./use-selected-thread-worktree", () => ({
43 useSelectedThreadWorktree: () => ({
44 selectedThreadCwd: "/repo",
45 selectedThreadWorktreePath: null,
46 }),
47}));
48vi.mock("./queries", () => ({
49 useBranches: () => ({ data: { refs: [] }, refresh: () => {} }),
50}));
51vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
52vi.mock("./atom-registry", () => ({ appAtomRegistry: {} }));
53vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: () => {} }));
54vi.mock("./use-vcs-action-state", () => ({
55 showGitActionResult: (result: { type: string; description?: string }) =>
56 state.results.push(result),
57}));
58vi.mock("../lib/uuid", () => ({ uuidv4: () => "action" }));
59vi.mock("./threads", () => ({
60 threadEnvironment: {
61 updateMetadata: async ({
62 environmentId,
63 input,
64 }: {
65 environmentId: string;
66 input: { branch: string; worktreePath: string | null };
67 }) => {
68 state.metadataRequests.push({ environmentId, branch: input.branch });
69 if (!state.scopes.has(AuthOrchestrationOperateScope)) {
70 return AsyncResult.failure(Cause.fail(new Error("Task operation denied")));
71 }
72 Object.assign(state.thread, input);
73 return AsyncResult.success(undefined);
74 },
75 },
76}));
77vi.mock("./vcs", () => ({
78 vcsEnvironment: {
79 refreshStatus: async () => {
80 state.statusRefreshes += 1;
81 return AsyncResult.success({ refName: state.branch });
82 },
83 switchRef: async ({ input }: { input: { refName: string } }) => {
84 state.branch = input.refName;
85 state.afterGitAction?.();
86 return AsyncResult.success({ refName: state.branch });
87 },
88 createRef: async ({ input }: { input: { refName: string } }) => {
89 state.branch = input.refName;
90 state.afterGitAction?.();
91 return AsyncResult.success({ refName: state.branch });
92 },
93 createWorktree: async ({ input }: { input: { newRefName: string } }) => {
94 state.worktrees.push("/repo-worktree");
95 state.afterGitAction?.();
96 return AsyncResult.success({
97 worktree: { path: "/repo-worktree", refName: input.newRefName },
98 });
99 },
100 pull: async () => AsyncResult.success({ status: "pulled", refName: state.branch }),
101 },
102 vcsActionManager: {
103 track: (_registry: unknown, _target: unknown, _operation: unknown, run: () => unknown) => run(),
104 runStackedAction: () => async (input: { action: string; featureBranch?: boolean }) => {
105 if (input.featureBranch) state.branch = "feature";
106 if (input.action === "commit") state.commits += 1;
107 if (input.action === "push") state.pushes += 1;
108 state.afterGitAction?.();
109 return AsyncResult.success({
110 branch: input.featureBranch
111 ? { status: "created", name: "feature" }
112 : { status: "skipped_not_requested" },
113 toast: { title: "Done", description: "Done", cta: { kind: "none" } },
114 });
115 },
116 },
117}));
118
119import { useSelectedThreadGitActions } from "./use-selected-thread-git-actions";
120
121describe("thread Git mutation permissions", () => {
122 beforeEach(() => {
123 state.scopes = new Set([AuthSourceControlWriteScope]);
124 state.primaryScopes = new Set([AuthSourceControlWriteScope, AuthOrchestrationOperateScope]);
125 state.branch = "main";
126 state.worktrees = [];
127 state.thread.branch = "main";
128 state.thread.worktreePath = null;
129 state.commits = 0;
130 state.pushes = 0;
131 state.metadataRequests = [];
132 state.results = [];
133 state.afterGitAction = undefined;
134 state.statusRefreshes = 0;
135 });
136
137 it.each([false, true])(
138 "requires task permission before creating and attaching a worktree: %s",
139 async (canOperate) => {
140 if (canOperate) state.scopes.add(AuthOrchestrationOperateScope);
141 const actions = useSelectedThreadGitActions();
142 const result = await actions.onCreateSelectedThreadWorktree({
143 baseBranch: "main",
144 newBranch: "feature/task",
145 });
146 expect(result).toEqual(
147 canOperate ? { worktree: { path: "/repo-worktree", refName: "feature/task" } } : null,
148 );
149 expect(state.worktrees).toEqual(canOperate ? ["/repo-worktree"] : []);
150 expect(state.thread.worktreePath).toBe(canOperate ? "/repo-worktree" : null);
151 expect(state.thread.branch).toBe(canOperate ? "feature/task" : "main");
152 },
153 );
154
155 it.each(["create", "checkout", "commit on new branch"] as const)(
156 "requires task permission before %s",
157 async (operation) => {
158 const actions = useSelectedThreadGitActions();
159 if (operation === "create") {
160 expect(await actions.onCreateSelectedThreadBranch("feature")).toBeNull();
161 }
162 if (operation === "checkout")
163 expect(await actions.onCheckoutSelectedThreadBranch("feature")).toBeNull();
164 if (operation === "commit on new branch")
165 await actions.onRunSelectedThreadGitAction({ action: "commit", featureBranch: true });
166 expect(state.branch).toBe("main");
167 expect(state.thread.branch).toBe("main");
168 expect(state.commits).toBe(0);
169 },
170 );
171
172 it("rechecks task permission when a retained menu callback runs", async () => {
173 state.scopes.add(AuthOrchestrationOperateScope);
174 const actions = useSelectedThreadGitActions();
175 state.scopes.delete(AuthOrchestrationOperateScope);
176 expect(
177 await actions.onCreateSelectedThreadWorktree({
178 baseBranch: "main",
179 newBranch: "feature/task",
180 }),
181 ).toBeNull();
182 expect(state.worktrees).toEqual([]);
183 expect(state.thread.worktreePath).toBeNull();
184 });
185
186 it("keeps ordinary commits and pushes available without task permission", async () => {
187 const actions = useSelectedThreadGitActions();
188 await actions.onRunSelectedThreadGitAction({ action: "commit" });
189 await actions.onRunSelectedThreadGitAction({ action: "push" });
190 expect(state.commits).toBe(1);
191 expect(state.pushes).toBe(1);
192 expect(state.thread.branch).toBe("main");
193 expect(state.metadataRequests).toEqual([]);
194 });
195
196 it.each(["create", "checkout", "worktree", "commit"] as const)(
197 "does not send thread metadata after task permission is revoked during %s",
198 async (operation) => {
199 state.scopes.add(AuthOrchestrationOperateScope);
200 state.afterGitAction = () => state.scopes.delete(AuthOrchestrationOperateScope);
201 const actions = useSelectedThreadGitActions();
202 if (operation === "create")
203 expect(await actions.onCreateSelectedThreadBranch("feature")).toBeNull();
204 if (operation === "checkout")
205 expect(await actions.onCheckoutSelectedThreadBranch("feature")).toBeNull();
206 if (operation === "worktree")
207 expect(
208 await actions.onCreateSelectedThreadWorktree({
209 baseBranch: "main",
210 newBranch: "feature",
211 }),
212 ).toBeNull();
213 if (operation === "commit")
214 expect(
215 await actions.onRunSelectedThreadGitAction({ action: "commit", featureBranch: true }),
216 ).toBeNull();
217 if (operation === "worktree") expect(state.worktrees).toEqual(["/repo-worktree"]);
218 else expect(state.branch).toBe("feature");
219 expect(state.thread.branch).toBe("main");
220 expect(state.thread.worktreePath).toBeNull();
221 expect(state.metadataRequests).toEqual([]);
222 expect(state.results.map((result) => result.type)).toEqual(["error"]);
223 },
224 );
225
226 it("refreshes the worktree status when the metadata update is denied after checkout", async () => {
227 state.scopes.add(AuthOrchestrationOperateScope);
228 // Git already switched the branch, so the sheet must show it even though the
229 // thread record could not be updated.
230 state.afterGitAction = () => state.scopes.delete(AuthOrchestrationOperateScope);
231 expect(
232 await useSelectedThreadGitActions().onCheckoutSelectedThreadBranch("feature"),
233 ).toBeNull();
234 expect(state.branch).toBe("feature");
235 expect(state.statusRefreshes).toBe(1);
236 });
237
238 it("uses a newly granted task permission for a retained branch callback", async () => {
239 state.primaryScopes.clear();
240 const actions = useSelectedThreadGitActions();
241 state.scopes.add(AuthOrchestrationOperateScope);
242 await actions.onCreateSelectedThreadBranch("feature");
243 expect(state.thread.branch).toBe("feature");
244 expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]);
245 });
246
247 it.each(["create", "checkout", "worktree"] as const)(
248 "returns the accepted %s result when only source-control permission is revoked after Git",
249 async (operation) => {
250 state.scopes.add(AuthOrchestrationOperateScope);
251 state.afterGitAction = () => state.scopes.delete(AuthSourceControlWriteScope);
252 const actions = useSelectedThreadGitActions();
253 const result =
254 operation === "create"
255 ? await actions.onCreateSelectedThreadBranch("feature/task")
256 : operation === "checkout"
257 ? await actions.onCheckoutSelectedThreadBranch("feature/task")
258 : await actions.onCreateSelectedThreadWorktree({
259 baseBranch: "main",
260 newBranch: "feature/task",
261 });
262 expect(result).toEqual(
263 operation === "worktree"
264 ? { worktree: { path: "/repo-worktree", refName: "feature/task" } }
265 : { refName: "feature/task" },
266 );
267 expect(state.thread.branch).toBe("feature/task");
268 expect(state.metadataRequests).toHaveLength(1);
269 expect(state.results).toEqual([]);
270 },
271 );
272
273 it("returns a successful checkout when the requested branch is already selected", async () => {
274 state.scopes.add(AuthOrchestrationOperateScope);
275
276 expect(await useSelectedThreadGitActions().onCheckoutSelectedThreadBranch("main")).toEqual({
277 refName: "main",
278 });
279 expect(state.branch).toBe("main");
280 expect(state.thread.branch).toBe("main");
281 expect(state.results).toEqual([]);
282 });
283});
apps/mobile/src/state/use-selected-thread-git-actions.ts+60-22
...@@ -7,7 +7,12 @@ import {...@@ -7,7 +7,12 @@ import {
7 type VcsActionOperation,7 type VcsActionOperation,
8 type VcsRef,8 type VcsRef,
9} from "@t3tools/client-runtime/state/vcs";9} from "@t3tools/client-runtime/state/vcs";
10import type { GitRunStackedActionResult } from "@t3tools/contracts";10import {
11 AuthOrchestrationOperateScope,
12 AuthSourceControlWriteScope,
13 EnvironmentAuthorizationError,
14 type GitRunStackedActionResult,
15} from "@t3tools/contracts";
11import {16import {
12 dedupeRemoteBranchesWithLocalMatches,17 dedupeRemoteBranchesWithLocalMatches,
13 sanitizeFeatureBranchName,18 sanitizeFeatureBranchName,
...@@ -20,6 +25,7 @@ import { threadEnvironment } from "../state/threads";...@@ -20,6 +25,7 @@ import { threadEnvironment } from "../state/threads";
20import { vcsActionManager, vcsEnvironment } from "../state/vcs";25import { vcsActionManager, vcsEnvironment } from "../state/vcs";
21import { uuidv4 } from "../lib/uuid";26import { uuidv4 } from "../lib/uuid";
22import { appAtomRegistry } from "./atom-registry";27import { appAtomRegistry } from "./atom-registry";
28import { readEnvironmentScope, useEnvironmentScope } from "./session";
23import { setPendingConnectionError } from "./use-remote-environment-registry";29import { setPendingConnectionError } from "./use-remote-environment-registry";
24import { useAtomCommand } from "./use-atom-command";30import { useAtomCommand } from "./use-atom-command";
25import { showGitActionResult } from "./use-vcs-action-state";31import { showGitActionResult } from "./use-vcs-action-state";
...@@ -36,6 +42,15 @@ export function useSelectedThreadGitActions() {...@@ -36,6 +42,15 @@ export function useSelectedThreadGitActions() {
36 const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false });42 const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false });
37 const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false });43 const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false });
38 const { selectedThread, selectedThreadProject } = useThreadSelection();44 const { selectedThread, selectedThreadProject } = useThreadSelection();
45 const canWriteSourceControl = useEnvironmentScope(
46 selectedThread?.environmentId ?? null,
47 AuthSourceControlWriteScope,
48 );
49 const canOperateThread = useEnvironmentScope(
50 selectedThread?.environmentId ?? null,
51 AuthOrchestrationOperateScope,
52 );
53 const canChangeThreadBranch = canWriteSourceControl && canOperateThread;
39 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();54 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
40 const runStackedAction = useAtomCommand(55 const runStackedAction = useAtomCommand(
41 vcsActionManager.runStackedAction({56 vcsActionManager.runStackedAction({
...@@ -63,6 +78,16 @@ export function useSelectedThreadGitActions() {...@@ -63,6 +78,16 @@ export function useSelectedThreadGitActions() {
63 readonly worktreePath?: string | null;78 readonly worktreePath?: string | null;
64 },79 },
65 ) => {80 ) => {
81 if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) {
82 return AsyncResult.failure<never, EnvironmentAuthorizationError>(
83 Cause.fail(
84 new EnvironmentAuthorizationError({
85 requiredScope: AuthOrchestrationOperateScope,
86 message: "This connection cannot update the thread's branch.",
87 }),
88 ),
89 );
90 }
66 return updateThreadMetadata({91 return updateThreadMetadata({
67 environmentId: thread.environmentId,92 environmentId: thread.environmentId,
68 input: {93 input: {
...@@ -131,9 +156,16 @@ export function useSelectedThreadGitActions() {...@@ -131,9 +156,16 @@ export function useSelectedThreadGitActions() {
131 readonly project: EnvironmentProject;156 readonly project: EnvironmentProject;
132 readonly cwd: string;157 readonly cwd: string;
133 }) => Promise<AtomCommandResult<T, E>>,158 }) => Promise<AtomCommandResult<T, E>>,
134 options?: { readonly managedExternally?: boolean },159 options?: { readonly managedExternally?: boolean; readonly changesThreadBranch?: boolean },
135 ): Promise<T | null> => {160 ): Promise<T | null> => {
136 if (!selectedThread || !selectedThreadProject || !selectedThreadCwd) {161 if (
162 !selectedThread ||
163 !selectedThreadProject ||
164 !selectedThreadCwd ||
165 !readEnvironmentScope(selectedThread.environmentId, AuthSourceControlWriteScope) ||
166 (options?.changesThreadBranch === true &&
167 !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope))
168 ) {
137 return null;169 return null;
138 }170 }
139171
...@@ -180,22 +212,24 @@ export function useSelectedThreadGitActions() {...@@ -180,22 +212,24 @@ export function useSelectedThreadGitActions() {
180 readonly worktreePath?: string | null;212 readonly worktreePath?: string | null;
181 };213 };
182 }): Promise<AtomCommandResult<void, unknown>> => {214 }): Promise<AtomCommandResult<void, unknown>> => {
183 if (input.nextThreadState) {215 // The Git mutation already landed; refresh what the worktree shows even
184 const updateResult = await updateThreadGitContext(input.thread, input.nextThreadState);216 // when the thread metadata update is denied, so the sheet does not keep
185 if (AsyncResult.isFailure(updateResult)) {217 // displaying the previous branch.
186 return AsyncResult.failure(updateResult.cause);218 const updateResult = input.nextThreadState
187 }219 ? await updateThreadGitContext(input.thread, input.nextThreadState)
188 }220 : AsyncResult.success(undefined);
189 branchState.refresh();221 branchState.refresh();
190 await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd });222 await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd });
191 return AsyncResult.success(undefined);223 return AsyncResult.isFailure(updateResult)
224 ? AsyncResult.failure(updateResult.cause)
225 : AsyncResult.success(undefined);
192 },226 },
193 [branchState, refreshSelectedThreadGitStatus, updateThreadGitContext],227 [branchState, refreshSelectedThreadGitStatus, updateThreadGitContext],
194 );228 );
195229
196 const onCheckoutSelectedThreadBranch = useCallback(230 const onCheckoutSelectedThreadBranch = useCallback(
197 async (branch: string) => {231 async (branch: string) => {
198 await runSelectedThreadGitMutation(232 return runSelectedThreadGitMutation(
199 "switch_ref",233 "switch_ref",
200 "Switching branch",234 "Switching branch",
201 async ({ thread, cwd }) => {235 async ({ thread, cwd }) => {
...@@ -216,6 +250,7 @@ export function useSelectedThreadGitActions() {...@@ -216,6 +250,7 @@ export function useSelectedThreadGitActions() {
216 });250 });
217 return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result;251 return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result;
218 },252 },
253 { changesThreadBranch: true },
219 );254 );
220 },255 },
221 [256 [
...@@ -228,7 +263,7 @@ export function useSelectedThreadGitActions() {...@@ -228,7 +263,7 @@ export function useSelectedThreadGitActions() {
228263
229 const onCreateSelectedThreadBranch = useCallback(264 const onCreateSelectedThreadBranch = useCallback(
230 async (branch: string) => {265 async (branch: string) => {
231 await runSelectedThreadGitMutation(266 return runSelectedThreadGitMutation(
232 "create_ref",267 "create_ref",
233 "Creating branch",268 "Creating branch",
234 async ({ thread, cwd }) => {269 async ({ thread, cwd }) => {
...@@ -249,6 +284,7 @@ export function useSelectedThreadGitActions() {...@@ -249,6 +284,7 @@ export function useSelectedThreadGitActions() {
249 });284 });
250 return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result;285 return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result;
251 },286 },
287 { changesThreadBranch: true },
252 );288 );
253 },289 },
254 [290 [
...@@ -261,7 +297,7 @@ export function useSelectedThreadGitActions() {...@@ -261,7 +297,7 @@ export function useSelectedThreadGitActions() {
261297
262 const onCreateSelectedThreadWorktree = useCallback(298 const onCreateSelectedThreadWorktree = useCallback(
263 async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => {299 async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => {
264 await runSelectedThreadGitMutation(300 return runSelectedThreadGitMutation(
265 "create_worktree",301 "create_worktree",
266 "Creating worktree",302 "Creating worktree",
267 async ({ thread, project }) => {303 async ({ thread, project }) => {
...@@ -287,6 +323,7 @@ export function useSelectedThreadGitActions() {...@@ -287,6 +323,7 @@ export function useSelectedThreadGitActions() {
287 });323 });
288 return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result;324 return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result;
289 },325 },
326 { changesThreadBranch: true },
290 );327 );
291 },328 },
292 [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState],329 [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState],
...@@ -337,14 +374,6 @@ export function useSelectedThreadGitActions() {...@@ -337,14 +374,6 @@ export function useSelectedThreadGitActions() {
337 return result;374 return result;
338 }375 }
339376
340 showGitActionResult({
341 type: "success",
342 title: result.value.toast.title,
343 description: result.value.toast.description,
344 prUrl:
345 result.value.toast.cta.kind === "open_pr" ? result.value.toast.cta.url : undefined,
346 });
347
348 if (result.value.branch.status === "created" && result.value.branch.name) {377 if (result.value.branch.status === "created" && result.value.branch.name) {
349 const syncResult = await syncSelectedThreadBranchState({378 const syncResult = await syncSelectedThreadBranchState({
350 thread,379 thread,
...@@ -360,9 +389,16 @@ export function useSelectedThreadGitActions() {...@@ -360,9 +389,16 @@ export function useSelectedThreadGitActions() {
360 } else {389 } else {
361 await refreshSelectedThreadGitStatus({ quiet: true, cwd });390 await refreshSelectedThreadGitStatus({ quiet: true, cwd });
362 }391 }
392 showGitActionResult({
393 type: "success",
394 title: result.value.toast.title,
395 description: result.value.toast.description,
396 prUrl:
397 result.value.toast.cta.kind === "open_pr" ? result.value.toast.cta.url : undefined,
398 });
363 return result;399 return result;
364 },400 },
365 { managedExternally: true },401 { managedExternally: true, changesThreadBranch: input.featureBranch === true },
366 );402 );
367 },403 },
368 [404 [
...@@ -375,6 +411,8 @@ export function useSelectedThreadGitActions() {...@@ -375,6 +411,8 @@ export function useSelectedThreadGitActions() {
375 );411 );
376412
377 return {413 return {
414 canWriteSourceControl,
415 canChangeThreadBranch,
378 refreshSelectedThreadGitStatus,416 refreshSelectedThreadGitStatus,
379 refreshSelectedThreadBranches,417 refreshSelectedThreadBranches,
380 onCheckoutSelectedThreadBranch,418 onCheckoutSelectedThreadBranch,
apps/server/src/auth/RpcAuthorization.test.ts+61
...@@ -4,8 +4,10 @@ import {...@@ -4,8 +4,10 @@ import {
4 AuthSettingsWriteScope,4 AuthSettingsWriteScope,
5 DEFAULT_SERVER_SETTINGS,5 DEFAULT_SERVER_SETTINGS,
6 ProviderInstanceId,6 ProviderInstanceId,
7 ThreadId,
7 AuthOrchestrationOperateScope,8 AuthOrchestrationOperateScope,
8 AuthOrchestrationReadScope,9 AuthOrchestrationReadScope,
10 AuthSourceControlWriteScope,
9 AuthRelayReadScope,11 AuthRelayReadScope,
10 AuthRelayWriteScope,12 AuthRelayWriteScope,
11 WS_METHODS,13 WS_METHODS,
...@@ -113,6 +115,28 @@ describe("RPC authorization scopes", () => {...@@ -113,6 +115,28 @@ describe("RPC authorization scopes", () => {
113 );115 );
114 });116 });
115117
118 it("requires source control writes to start, retry, or cancel project clones", () => {
119 for (const method of [
120 WS_METHODS.projectCloneStart,
121 WS_METHODS.projectCloneRetry,
122 WS_METHODS.projectCloneCancel,
123 ]) {
124 expect(requiredScopeForRpcMethod(method)).toBe(AuthSourceControlWriteScope);
125 }
126 expect(requiredScopeForRpcMethod(WS_METHODS.subscribeProjectClones)).toBe(
127 AuthOrchestrationReadScope,
128 );
129 });
130
131 it("separates viewing pull request file progress from writing it", () => {
132 expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsFilesViewed)).toBe(
133 AuthOrchestrationReadScope,
134 );
135 expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsSetFilesViewed)).toBe(
136 AuthSourceControlWriteScope,
137 );
138 });
139
116 it("rejects unknown RPC method names", () => {140 it("rejects unknown RPC method names", () => {
117 for (const method of ["server.notRegistered", "toString", "constructor"]) {141 for (const method of ["server.notRegistered", "toString", "constructor"]) {
118 expect(() => requiredScopeForRpcMethod(method)).toThrow(142 expect(() => requiredScopeForRpcMethod(method)).toThrow(
...@@ -240,3 +264,40 @@ describe("settings mutation authorization", () => {...@@ -240,3 +264,40 @@ describe("settings mutation authorization", () => {
240 }).pipe(Effect.scoped),264 }).pipe(Effect.scoped),
241 );265 );
242});266});
267
268it.effect("requires task permission before attaching a prepared worktree to a thread", () =>
269 Effect.gen(function* () {
270 const group = WsRpcGroup.omit(
271 ...[...WsRpcGroup.requests.keys()].filter(
272 (
273 tag,
274 ): tag is Exclude<
275 keyof typeof RPC_REQUIRED_SCOPES,
276 typeof WS_METHODS.gitPreparePullRequestThread
277 > => tag !== WS_METHODS.gitPreparePullRequestThread,
278 ),
279 );
280 let handled = false;
281 const client = yield* RpcTest.makeClient(group).pipe(
282 Effect.provide(
283 Layer.mergeAll(
284 group.toLayerHandler(WS_METHODS.gitPreparePullRequestThread, () =>
285 Effect.sync(() => {
286 handled = true;
287 }).pipe(Effect.andThen(Effect.never)),
288 ),
289 RpcAuthorization.layer([AuthSourceControlWriteScope]),
290 ),
291 ),
292 );
293 expect(
294 yield* client[WS_METHODS.gitPreparePullRequestThread]({
295 cwd: "/repo",
296 reference: "42",
297 mode: "worktree",
298 threadId: ThreadId.make("thread"),
299 }).pipe(Effect.flip),
300 ).toMatchObject({ requiredScope: AuthOrchestrationOperateScope });
301 expect(handled).toBe(false);
302 }).pipe(Effect.scoped),
303);
apps/server/src/auth/RpcAuthorization.ts+13-29
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 CLIENT_GUARDED_RPC_SCOPES,2 CLIENT_GUARDED_RPC_SCOPES,
3 type DeviceListInput,3 type DeviceListInput,
4 clientRpcRequiredScopes,
4 AuthAccessReadScope,5 AuthAccessReadScope,
5 ServerSettingsPatch,6 ServerSettingsPatch,
6 ProviderInstanceMutation,7 ProviderInstanceMutation,
...@@ -120,15 +121,6 @@ export const RPC_REQUIRED_SCOPES = {...@@ -120,15 +121,6 @@ export const RPC_REQUIRED_SCOPES = {
120 [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,121 [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
121 [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope,122 [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope,
122 [WS_METHODS.pullRequestsFilesViewed]: AuthOrchestrationReadScope,123 [WS_METHODS.pullRequestsFilesViewed]: AuthOrchestrationReadScope,
123 [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope,
124 [WS_METHODS.pullRequestsUpdate]: AuthOrchestrationOperateScope,
125 [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope,
126 [WS_METHODS.pullRequestsUpdateComment]: AuthOrchestrationOperateScope,
127 [WS_METHODS.pullRequestsSubmitReview]: AuthOrchestrationOperateScope,
128 [WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope,
129 [WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope,
130 [WS_METHODS.pullRequestsSetReaction]: AuthOrchestrationOperateScope,
131 [WS_METHODS.pullRequestsSetFilesViewed]: AuthOrchestrationOperateScope,
132 // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only124 // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only
133 // client pressing refresh must not be told it may not look again.125 // client pressing refresh must not be told it may not look again.
134 [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope,126 [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope,
...@@ -136,15 +128,8 @@ export const RPC_REQUIRED_SCOPES = {...@@ -136,15 +128,8 @@ export const RPC_REQUIRED_SCOPES = {
136 // The candidate list is a read like the detail beside it; asking somebody for a review is a128 // The candidate list is a read like the detail beside it; asking somebody for a review is a
137 // write like every other one.129 // write like every other one.
138 [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope,130 [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope,
139 [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope,
140 [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope,131 [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope,
141 [WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope,
142 [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,132 [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
143 [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,
144 [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
145 [WS_METHODS.projectCloneStart]: AuthOrchestrationOperateScope,
146 [WS_METHODS.projectCloneCancel]: AuthOrchestrationOperateScope,
147 [WS_METHODS.projectCloneRetry]: AuthOrchestrationOperateScope,
148 [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope,133 [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope,
149 [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,134 [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
150 [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,135 [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
...@@ -167,16 +152,8 @@ export const RPC_REQUIRED_SCOPES = {...@@ -167,16 +152,8 @@ export const RPC_REQUIRED_SCOPES = {
167 [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope,152 [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope,
168 [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,153 [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,
169 [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,154 [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,
170 [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope,155 [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope,
171 [WS_METHODS.gitRunStackedAction]: AuthOrchestrationOperateScope,
172 [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope,
173 [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope,
174 [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,156 [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
175 [WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope,
176 [WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope,
177 [WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope,
178 [WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope,
179 [WS_METHODS.vcsInit]: AuthOrchestrationOperateScope,
180 [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope,157 [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope,
181 [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope,158 [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope,
182 [WS_METHODS.terminalOpen]: AuthTerminalOperateScope,159 [WS_METHODS.terminalOpen]: AuthTerminalOperateScope,
...@@ -246,13 +223,20 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => {...@@ -246,13 +223,20 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => {
246 : [...new Set([...scopes, AuthProvidersManageScope])];223 : [...new Set([...scopes, AuthProvidersManageScope])];
247};224};
248225
226const requiredScopesForRpcCall = (
227 method: string,
228 payload: unknown,
229): ReadonlyArray<AuthEnvironmentScope> => {
230 if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload);
231 const guarded = clientRpcRequiredScopes(method, payload);
232 if (guarded.length > 0) return guarded;
233 return [requiredScopeForRpcMethod(method)];
234};
235
249/** Authorizes every RPC on one connection against that connection's session scopes. */236/** Authorizes every RPC on one connection against that connection's session scopes. */
250export const layer = (scopes: ReadonlyArray<AuthEnvironmentScope>) =>237export const layer = (scopes: ReadonlyArray<AuthEnvironmentScope>) =>
251 Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) => {238 Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) => {
252 const requiredScopes =239 const requiredScopes = requiredScopesForRpcCall(rpc._tag, payload);
253 rpc._tag === WS_METHODS.serverUpdateSettings
254 ? requiredScopesForSettingsUpdate(payload)
255 : [requiredScopeForRpcMethod(rpc._tag)];
256 const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope));240 const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope));
257 return requiredScope === undefined ? effect : Effect.fail(rpcAuthorizationError(requiredScope));241 return requiredScope === undefined ? effect : Effect.fail(rpcAuthorizationError(requiredScope));
258 });242 });
apps/server/src/auth/http.ts+2
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 AuthRelayReadScope,10 AuthRelayReadScope,
11 AuthRelayWriteScope,11 AuthRelayWriteScope,
12 AuthReviewWriteScope,12 AuthReviewWriteScope,
13 AuthSourceControlWriteScope,
13 AuthTerminalOperateScope,14 AuthTerminalOperateScope,
14 EnvironmentAuthInvalidError,15 EnvironmentAuthInvalidError,
15 type EnvironmentAuthInvalidReason,16 type EnvironmentAuthInvalidReason,
...@@ -374,6 +375,7 @@ export const layer = HttpApiBuilder.group(...@@ -374,6 +375,7 @@ export const layer = HttpApiBuilder.group(
374 AuthEnvironmentMaintainScope,375 AuthEnvironmentMaintainScope,
375 AuthTerminalOperateScope,376 AuthTerminalOperateScope,
376 AuthReviewWriteScope,377 AuthReviewWriteScope,
378 AuthSourceControlWriteScope,
377 AuthAccessReadScope,379 AuthAccessReadScope,
378 AuthAccessWriteScope,380 AuthAccessWriteScope,
379 AuthRelayReadScope,381 AuthRelayReadScope,
apps/web/src/components/BranchPicker.tsx+3
...@@ -264,6 +264,7 @@ export function BranchPickerRefItem({...@@ -264,6 +264,7 @@ export function BranchPickerRefItem({
264 projectCwd: activeProjectCwd,264 projectCwd: activeProjectCwd,
265 index,265 index,
266 value,266 value,
267 disabled,
267 onClick,268 onClick,
268 onContextMenu,269 onContextMenu,
269}: {270}: {
...@@ -271,6 +272,7 @@ export function BranchPickerRefItem({...@@ -271,6 +272,7 @@ export function BranchPickerRefItem({
271 projectCwd: string | null;272 projectCwd: string | null;
272 index: number;273 index: number;
273 value?: string;274 value?: string;
275 disabled?: boolean;
274 onClick: ComponentProps<typeof ComboboxItem>["onClick"];276 onClick: ComponentProps<typeof ComboboxItem>["onClick"];
275 onContextMenu?: ComponentProps<typeof ComboboxItem>["onContextMenu"];277 onContextMenu?: ComponentProps<typeof ComboboxItem>["onContextMenu"];
276}) {278}) {
...@@ -288,6 +290,7 @@ export function BranchPickerRefItem({...@@ -288,6 +290,7 @@ export function BranchPickerRefItem({
288 : null;290 : null;
289 return (291 return (
290 <ComboboxItem292 <ComboboxItem
293 disabled={disabled}
291 hideIndicator294 hideIndicator
292 key={itemValue}295 key={itemValue}
293 index={index}296 index={index}
apps/web/src/components/BranchToolbarBranchSelector.tsx+51-5
...@@ -8,7 +8,14 @@ import {...@@ -8,7 +8,14 @@ import {
8 isAtomCommandInterrupted,8 isAtomCommandInterrupted,
9 squashAtomCommandFailure,9 squashAtomCommandFailure,
10} from "@t3tools/client-runtime/state/runtime";10} from "@t3tools/client-runtime/state/runtime";
11import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";11import {
12 AuthOrchestrationOperateScope,
13 AuthSourceControlWriteScope,
14 type ContextMenuItem,
15 type EnvironmentId,
16 type VcsRef,
17 type ThreadId,
18} from "@t3tools/contracts";
12import { ChevronDownIcon, GitBranchIcon } from "lucide-react";19import { ChevronDownIcon, GitBranchIcon } from "lucide-react";
13import {20import {
14 useCallback,21 useCallback,
...@@ -30,6 +37,7 @@ import { useOpenPrLink } from "../lib/openPullRequestLink";...@@ -30,6 +37,7 @@ import { useOpenPrLink } from "../lib/openPullRequestLink";
30import { usePaginatedBranches } from "../state/queries";37import { usePaginatedBranches } from "../state/queries";
31import { useProject, useThreadShell } from "../state/entities";38import { useProject, useThreadShell } from "../state/entities";
32import { useEnvironmentQuery } from "../state/query";39import { useEnvironmentQuery } from "../state/query";
40import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
33import { threadEnvironment } from "../state/threads";41import { threadEnvironment } from "../state/threads";
34import { useAtomCommand } from "../state/use-atom-command";42import { useAtomCommand } from "../state/use-atom-command";
35import { vcsEnvironment } from "../state/vcs";43import { vcsEnvironment } from "../state/vcs";
...@@ -110,6 +118,8 @@ export function BranchToolbarBranchSelector({...@@ -110,6 +118,8 @@ export function BranchToolbarBranchSelector({
110 onComposerFocusRequest,118 onComposerFocusRequest,
111}: BranchToolbarBranchSelectorProps) {119}: BranchToolbarBranchSelectorProps) {
112 const composerFloatingLayerProps = useComposerMenuProps();120 const composerFloatingLayerProps = useComposerMenuProps();
121 const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope);
122 const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
113 const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop");123 const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop");
114 const updateThreadMetadata = useAtomCommand(124 const updateThreadMetadata = useAtomCommand(
115 threadEnvironment.updateMetadata,125 threadEnvironment.updateMetadata,
...@@ -153,6 +163,8 @@ export function BranchToolbarBranchSelector({...@@ -153,6 +163,8 @@ export function BranchToolbarBranchSelector({
153 const activeProjectCwd = activeProject?.workspaceRoot ?? null;163 const activeProjectCwd = activeProject?.workspaceRoot ?? null;
154 const branchCwd = activeWorktreePath ?? activeProjectCwd;164 const branchCwd = activeWorktreePath ?? activeProjectCwd;
155 const hasServerThread = serverThread !== null;165 const hasServerThread = serverThread !== null;
166 const canUpdateThreadBranch = !hasServerThread || canOperateThread;
167 const canChangeThreadBranch = canWriteSourceControl && canUpdateThreadBranch;
156 const effectiveEnvMode =168 const effectiveEnvMode =
157 effectiveEnvModeOverride ??169 effectiveEnvModeOverride ??
158 resolveEffectiveEnvMode({170 resolveEffectiveEnvMode({
...@@ -166,7 +178,12 @@ export function BranchToolbarBranchSelector({...@@ -166,7 +178,12 @@ export function BranchToolbarBranchSelector({
166 // ---------------------------------------------------------------------------178 // ---------------------------------------------------------------------------
167 const setThreadBranch = useCallback(179 const setThreadBranch = useCallback(
168 (branch: string | null, worktreePath: string | null, automatic = false) => {180 (branch: string | null, worktreePath: string | null, automatic = false) => {
169 if (!activeThreadId || !activeProject) return;181 if (
182 !activeThreadId ||
183 !activeProject ||
184 (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
185 )
186 return;
170 if (serverSession && worktreePath !== activeWorktreePath) {187 if (serverSession && worktreePath !== activeWorktreePath) {
171 void stopThreadSession({188 void stopThreadSession({
172 environmentId,189 environmentId,
...@@ -273,8 +290,11 @@ export function BranchToolbarBranchSelector({...@@ -273,8 +290,11 @@ export function BranchToolbarBranchSelector({
273 const isSelectingWorktreeBase =290 const isSelectingWorktreeBase =
274 effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath;291 effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath;
275 const checkoutPullRequestItemValue =292 const checkoutPullRequestItemValue =
276 prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null;293 canChangeThreadBranch && prReference && onCheckoutPullRequestRequest
277 const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0;294 ? `__checkout_pull_request__:${prReference}`
295 : null;
296 const canCreateBranch =
297 canChangeThreadBranch && !isSelectingWorktreeBase && trimmedBranchQuery.length > 0;
278 // The ref is created under its sanitized name, so the collision check has to298 // The ref is created under its sanitized name, so the collision check has to
279 // use that name too. Matching on the raw query would offer to create a ref299 // use that name too. Matching on the raw query would offer to create a ref
280 // that already exists whenever sanitizing changes the name.300 // that already exists whenever sanitizing changes the name.
...@@ -392,6 +412,20 @@ export function BranchToolbarBranchSelector({...@@ -392,6 +412,20 @@ export function BranchToolbarBranchSelector({
392 );412 );
393413
394 const runBranchAction = (action: () => Promise<void>) => {414 const runBranchAction = (action: () => Promise<void>) => {
415 if (
416 !readEnvironmentScope(environmentId, AuthSourceControlWriteScope) ||
417 (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
418 ) {
419 // The menu already closed when the item was chosen; explain the no-op.
420 toastManager.add(
421 stackedThreadToast({
422 type: "error",
423 title: "Action unavailable",
424 description: "This connection cannot change the thread's branch.",
425 }),
426 );
427 return;
428 }
395 startBranchActionTransition(async () => {429 startBranchActionTransition(async () => {
396 await action();430 await action();
397 branchRefState.refresh();431 branchRefState.refresh();
...@@ -400,7 +434,7 @@ export function BranchToolbarBranchSelector({...@@ -400,7 +434,7 @@ export function BranchToolbarBranchSelector({
400 };434 };
401435
402 const selectBranch = (refName: VcsRef) => {436 const selectBranch = (refName: VcsRef) => {
403 if (!branchCwd || !activeProjectCwd || isBranchActionPending) return;437 if (!canUpdateThreadBranch || !branchCwd || !activeProjectCwd || isBranchActionPending) return;
404438
405 if (isSelectingWorktreeBase) {439 if (isSelectingWorktreeBase) {
406 setThreadBranch(refName.name, null);440 setThreadBranch(refName.name, null);
...@@ -461,6 +495,7 @@ export function BranchToolbarBranchSelector({...@@ -461,6 +495,7 @@ export function BranchToolbarBranchSelector({
461 };495 };
462496
463 const createRef = (rawName: string) => {497 const createRef = (rawName: string) => {
498 if (!canChangeThreadBranch) return;
464 const name = sanitizeNewRefName(rawName);499 const name = sanitizeNewRefName(rawName);
465 if (!branchCwd || !name || isBranchActionPending) return;500 if (!branchCwd || !name || isBranchActionPending) return;
466501
...@@ -647,6 +682,17 @@ export function BranchToolbarBranchSelector({...@@ -647,6 +682,17 @@ export function BranchToolbarBranchSelector({
647 projectCwd={activeProjectCwd}682 projectCwd={activeProjectCwd}
648 index={index}683 index={index}
649 value={itemValue}684 value={itemValue}
685 disabled={
686 !canUpdateThreadBranch ||
687 (!canWriteSourceControl &&
688 !isSelectingWorktreeBase &&
689 (!activeProjectCwd ||
690 !resolveBranchSelectionTarget({
691 activeProjectCwd,
692 activeWorktreePath,
693 refName,
694 }).reuseExistingWorktree))
695 }
650 onClick={() => selectPickerItem(itemValue)}696 onClick={() => selectPickerItem(itemValue)}
651 onContextMenu={(event) => handleBranchContextMenu(event, itemValue)}697 onContextMenu={(event) => handleBranchContextMenu(event, itemValue)}
652 />698 />
apps/web/src/components/ChatView.tsx+20-8
...@@ -50,6 +50,7 @@ import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue";...@@ -50,6 +50,7 @@ import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue";
50import {50import {
51 AuthOrchestrationOperateScope,51 AuthOrchestrationOperateScope,
52 AuthSettingsWriteScope,52 AuthSettingsWriteScope,
53 AuthSourceControlWriteScope,
53 type AssistantCitation,54 type AssistantCitation,
54 type ChatFileAttachment,55 type ChatFileAttachment,
55 CommandId,56 CommandId,
...@@ -397,6 +398,7 @@ import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnect...@@ -397,6 +398,7 @@ import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnect
397import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";398import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
398import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";399import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
399import { useEnvironmentQuery } from "../state/query";400import { useEnvironmentQuery } from "../state/query";
401import { useEnvironmentScope } from "~/state/session";
400import {402import {
401 environmentServerConfigsAtom,403 environmentServerConfigsAtom,
402 primaryServerAvailableEditorsAtom,404 primaryServerAvailableEditorsAtom,
...@@ -559,11 +561,7 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at...@@ -559,11 +561,7 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at
559import { appAtomRegistry } from "../rpc/atomRegistry";561import { appAtomRegistry } from "../rpc/atomRegistry";
560import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles";562import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles";
561import { assetEnvironment } from "../state/assets";563import { assetEnvironment } from "../state/assets";
562import {564import { readEnvironmentScope, readPreparedConnection } from "../state/session";
563 readEnvironmentScope,
564 readPreparedConnection,
565 useEnvironmentScope,
566} from "../state/session";
567import { useAtomCommand } from "../state/use-atom-command";565import { useAtomCommand } from "../state/use-atom-command";
568import { useOrchestrationCommand } from "../state/use-orchestration-command";566import { useOrchestrationCommand } from "../state/use-orchestration-command";
569import { useAtomQueryRunner } from "../state/use-atom-query-runner";567import { useAtomQueryRunner } from "../state/use-atom-query-runner";
...@@ -1577,6 +1575,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -1577,6 +1575,7 @@ export default function ChatView(props: ChatViewProps) {
1577 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {1575 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
1578 reportFailure: false,1576 reportFailure: false,
1579 });1577 });
1578 const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope);
1580 const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });1579 const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
1581 const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, {1580 const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, {
1582 reportFailure: false,1581 reportFailure: false,
...@@ -2164,7 +2163,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2164,7 +2163,7 @@ export default function ChatView(props: ChatViewProps) {
2164 (isServerThread ? activeThread?.runtimeMode : undefined) ??2163 (isServerThread ? activeThread?.runtimeMode : undefined) ??
2165 defaultRuntimeMode;2164 defaultRuntimeMode;
2166 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;2165 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
2167 const canCheckoutPullRequestIntoThread = isLocalDraftThread;2166 const canCheckoutPullRequestIntoThread = canWriteSourceControl && isLocalDraftThread;
2168 const activeThreadId = activeThread?.id ?? null;2167 const activeThreadId = activeThread?.id ?? null;
2169 // Prefer the larger of turn-item-committed ids and projection messages so2168 // Prefer the larger of turn-item-committed ids and projection messages so
2170 // env lock does not unlock while turn items lag projection hydration.2169 // env lock does not unlock while turn items lag projection hydration.
...@@ -7161,6 +7160,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7161,6 +7160,7 @@ export default function ChatView(props: ChatViewProps) {
7161 });7160 });
7162 }, [activeBranchMismatchKey, showBranchMismatchBanner]);7161 }, [activeBranchMismatchKey, showBranchMismatchBanner]);
7163 const handleSwitchCheckoutToThread = useCallback(async () => {7162 const handleSwitchCheckoutToThread = useCallback(async () => {
7163 if (!canWriteSourceControl) return;
7164 if (7164 if (
7165 !activeProjectCwd ||7165 !activeProjectCwd ||
7166 !activeThread ||7166 !activeThread ||
...@@ -7191,6 +7191,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -7191,6 +7191,10 @@ export default function ChatView(props: ChatViewProps) {
7191 return;7191 return;
7192 }7192 }
71937193
7194 if (!readEnvironmentScope(environmentId, AuthSourceControlWriteScope)) {
7195 setIsRestoringThreadBranch(false);
7196 return;
7197 }
7194 const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch;7198 const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch;
7195 if (nextBranch !== activeThread.branch) {7199 if (nextBranch !== activeThread.branch) {
7196 const updateResult = await updateThreadMetadata({7200 const updateResult = await updateThreadMetadata({
...@@ -7216,6 +7220,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7216,6 +7220,7 @@ export default function ChatView(props: ChatViewProps) {
7216 setIsRestoringThreadBranch(false);7220 setIsRestoringThreadBranch(false);
7217 scheduleComposerFocus();7221 scheduleComposerFocus();
7218 }, [7222 }, [
7223 canWriteSourceControl,
7219 activeProjectCwd,7224 activeProjectCwd,
7220 activeThread,7225 activeThread,
7221 environmentId,7226 environmentId,
...@@ -7622,12 +7627,17 @@ export default function ChatView(props: ChatViewProps) {...@@ -7622,12 +7627,17 @@ export default function ChatView(props: ChatViewProps) {
7622 selectedProvider,7627 selectedProvider,
7623 ]);7628 ]);
7624 const handleRestoreThreadBranch = useCallback(() => {7629 const handleRestoreThreadBranch = useCallback(() => {
7630 if (!canWriteSourceControl) return;
7625 if (gitStatusQuery.data?.hasWorkingTreeChanges) {7631 if (gitStatusQuery.data?.hasWorkingTreeChanges) {
7626 setBranchRestoreConfirmOpen(true);7632 setBranchRestoreConfirmOpen(true);
7627 return;7633 return;
7628 }7634 }
7629 void handleSwitchCheckoutToThread();7635 void handleSwitchCheckoutToThread();
7630 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);7636 }, [
7637 canWriteSourceControl,
7638 gitStatusQuery.data?.hasWorkingTreeChanges,
7639 handleSwitchCheckoutToThread,
7640 ]);
7631 const feedbackBannerItems = useMemo(7641 const feedbackBannerItems = useMemo(
7632 () =>7642 () =>
7633 feedbackSubmissions.flatMap((submission) => {7643 feedbackSubmissions.flatMap((submission) => {
...@@ -7722,7 +7732,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7722,7 +7732,7 @@ export default function ChatView(props: ChatViewProps) {
7722 <Button7732 <Button
7723 size="xs"7733 size="xs"
7724 variant="ghost"7734 variant="ghost"
7725 disabled={isRestoringThreadBranch}7735 disabled={!canWriteSourceControl || isRestoringThreadBranch}
7726 onClick={handleRestoreThreadBranch}7736 onClick={handleRestoreThreadBranch}
7727 >7737 >
7728 {isRestoringThreadBranch ? "Restoring..." : "Restore branch"}7738 {isRestoringThreadBranch ? "Restoring..." : "Restore branch"}
...@@ -7740,6 +7750,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7740,6 +7750,7 @@ export default function ChatView(props: ChatViewProps) {
7740 activeBranchMismatchKey,7750 activeBranchMismatchKey,
7741 activeThreadShell,7751 activeThreadShell,
7742 serverRuntime?.usageLimitResetAt,7752 serverRuntime?.usageLimitResetAt,
7753 canWriteSourceControl,
7743 feedbackBannerItems,7754 feedbackBannerItems,
7744 limitRecoveryBanner,7755 limitRecoveryBanner,
7745 handleRestoreThreadBranch,7756 handleRestoreThreadBranch,
...@@ -11654,6 +11665,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11654,6 +11665,7 @@ export default function ChatView(props: ChatViewProps) {
11654 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>11665 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
11655 <Button11666 <Button
11656 variant="default"11667 variant="default"
11668 disabled={!canWriteSourceControl}
11657 onClick={() => {11669 onClick={() => {
11658 setBranchRestoreConfirmOpen(false);11670 setBranchRestoreConfirmOpen(false);
11659 void handleSwitchCheckoutToThread();11671 void handleSwitchCheckoutToThread();
apps/web/src/components/CommandPalette.tsx+45-4
...@@ -30,6 +30,8 @@ import {...@@ -30,6 +30,8 @@ import {
30 squashAtomCommandFailure,30 squashAtomCommandFailure,
31} from "@t3tools/client-runtime/state/runtime";31} from "@t3tools/client-runtime/state/runtime";
32import {32import {
33 AuthOrchestrationOperateScope,
34 AuthSourceControlWriteScope,
33 type DesktopWslState,35 type DesktopWslState,
34 type EnvironmentId,36 type EnvironmentId,
35 type EnvironmentMachineKind,37 type EnvironmentMachineKind,
...@@ -103,6 +105,7 @@ import { projectEnvironment } from "../state/projects";...@@ -103,6 +105,7 @@ import { projectEnvironment } from "../state/projects";
103import { useEnvironmentQuery } from "../state/query";105import { useEnvironmentQuery } from "../state/query";
104import { serverEnvironment } from "../state/server";106import { serverEnvironment } from "../state/server";
105import { threadEnvironment } from "../state/threads";107import { threadEnvironment } from "../state/threads";
108import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
106import { sourceControlEnvironment } from "../state/sourceControl";109import { sourceControlEnvironment } from "../state/sourceControl";
107import { useAtomCommand } from "../state/use-atom-command";110import { useAtomCommand } from "../state/use-atom-command";
108import { useAtomQueryRunner } from "../state/use-atom-query-runner";111import { useAtomQueryRunner } from "../state/use-atom-query-runner";
...@@ -1039,6 +1042,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1039,6 +1042,7 @@ function OpenCommandPaletteDialog(props: {
1039 [addProjectEnvironmentOptions, environments],1042 [addProjectEnvironmentOptions, environments],
1040 );1043 );
1041 const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId;1044 const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId;
1045 const canCreateProject = useEnvironmentScope(browseEnvironmentId, AuthOrchestrationOperateScope);
1042 const browseEnvironment =1046 const browseEnvironment =
1043 environments.find((environment) => environment.environmentId === browseEnvironmentId) ?? null;1047 environments.find((environment) => environment.environmentId === browseEnvironmentId) ?? null;
1044 // A desktop-local secondary backend (today: the WSL backend). The picker is1048 // A desktop-local secondary backend (today: the WSL backend). The picker is
...@@ -2418,6 +2422,16 @@ function OpenCommandPaletteDialog(props: {...@@ -2418,6 +2422,16 @@ function OpenCommandPaletteDialog(props: {
2418 return;2422 return;
2419 }2423 }
24202424
2425 if (!readEnvironmentScope(input.environmentId, AuthOrchestrationOperateScope)) {
2426 toastManager.add(
2427 stackedThreadToast({
2428 type: "error",
2429 title: "Cannot add project",
2430 description: "This connection cannot add projects.",
2431 }),
2432 );
2433 return;
2434 }
2421 const projectId = newProjectId();2435 const projectId = newProjectId();
2422 const createResult = await createProject({2436 const createResult = await createProject({
2423 environmentId: input.environmentId,2437 environmentId: input.environmentId,
...@@ -2607,9 +2621,20 @@ function OpenCommandPaletteDialog(props: {...@@ -2607,9 +2621,20 @@ function OpenCommandPaletteDialog(props: {
2607 }2621 }
26082622
2609 const rawDestination = (destinationPathInput ?? query).trim();2623 const rawDestination = (destinationPathInput ?? query).trim();
2610 if (rawDestination.length === 0 || isRemoteProjectCloning) {2624 if (
2625 !readEnvironmentScope(addProjectCloneFlow.environmentId, AuthSourceControlWriteScope) ||
2626 !readEnvironmentScope(addProjectCloneFlow.environmentId, AuthOrchestrationOperateScope)
2627 ) {
2628 toastManager.add(
2629 stackedThreadToast({
2630 type: "error",
2631 title: "Clone unavailable",
2632 description: "This connection needs permission to write source control and add projects.",
2633 }),
2634 );
2611 return;2635 return;
2612 }2636 }
2637 if (rawDestination.length === 0 || isRemoteProjectCloning) return;
26132638
2614 if (isUnsupportedWindowsProjectPath(rawDestination, browseEnvironmentPlatform)) {2639 if (isUnsupportedWindowsProjectPath(rawDestination, browseEnvironmentPlatform)) {
2615 toastManager.add(2640 toastManager.add(
...@@ -2948,9 +2973,21 @@ function OpenCommandPaletteDialog(props: {...@@ -2948,9 +2973,21 @@ function OpenCommandPaletteDialog(props: {
2948 getCommandPaletteInputPlaceholder(paletteMode));2973 getCommandPaletteInputPlaceholder(paletteMode));
2949 const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse";2974 const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse";
2950 const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false;2975 const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false;
2976 const canWriteSourceControl = useEnvironmentScope(
2977 addProjectCloneFlow?.environmentId ?? null,
2978 AuthSourceControlWriteScope,
2979 );
2980 const canCreateClonedProject = useEnvironmentScope(
2981 addProjectCloneFlow?.environmentId ?? null,
2982 AuthOrchestrationOperateScope,
2983 );
2984 const canCloneProject = canWriteSourceControl && canCreateClonedProject;
2985 const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm";
2951 const canSubmitBrowsePath =2986 const canSubmitBrowsePath =
2952 isBrowsing &&2987 isBrowsing &&
2953 !relativePathNeedsActiveProject &&2988 !relativePathNeedsActiveProject &&
2989 canCreateProject &&
2990 (!isCloneDestinationStep || canCloneProject) &&
2954 canCreateProjectInEnvironment(browseEnvironment?.connection.phase);2991 canCreateProjectInEnvironment(browseEnvironment?.connection.phase);
2955 const willCreateProjectPath =2992 const willCreateProjectPath =
2956 canSubmitBrowsePath &&2993 canSubmitBrowsePath &&
...@@ -2960,7 +2997,6 @@ function OpenCommandPaletteDialog(props: {...@@ -2960,7 +2997,6 @@ function OpenCommandPaletteDialog(props: {
2960 (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null);2997 (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null);
2961 const useMetaForMod = isMacPlatform(navigator.platform);2998 const useMetaForMod = isMacPlatform(navigator.platform);
2962 const submitModifierLabel = useMetaForMod ? "\u2318" : "Ctrl";2999 const submitModifierLabel = useMetaForMod ? "\u2318" : "Ctrl";
2963 const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm";
2964 const submitActionLabel = isCloneDestinationStep3000 const submitActionLabel = isCloneDestinationStep
2965 ? willCreateProjectPath3001 ? willCreateProjectPath
2966 ? "Create & Clone"3002 ? "Create & Clone"
...@@ -3327,9 +3363,10 @@ function OpenCommandPaletteDialog(props: {...@@ -3327,9 +3363,10 @@ function OpenCommandPaletteDialog(props: {
3327 className="absolute inset-e-2.5 top-1/2 -translate-y-1/2"3363 className="absolute inset-e-2.5 top-1/2 -translate-y-1/2"
3328 aria-label={`${submitActionLabel} (${addShortcutLabel})`}3364 aria-label={`${submitActionLabel} (${addShortcutLabel})`}
3329 disabled={3365 disabled={
3366 !canCreateProject ||
3330 !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) ||3367 !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) ||
3331 relativePathNeedsActiveProject ||3368 relativePathNeedsActiveProject ||
3332 (isCloneDestinationStep && isRemoteProjectPending)3369 (isCloneDestinationStep && (!canCloneProject || isRemoteProjectPending))
3333 }3370 }
3334 onMouseDown={(event) => {3371 onMouseDown={(event) => {
3335 event.preventDefault();3372 event.preventDefault();
...@@ -3355,7 +3392,11 @@ function OpenCommandPaletteDialog(props: {...@@ -3355,7 +3392,11 @@ function OpenCommandPaletteDialog(props: {
3355 </KbdGroup>3392 </KbdGroup>
3356 </TooltipTrigger>3393 </TooltipTrigger>
3357 <TooltipPopup side="top">3394 <TooltipPopup side="top">
3358 {submitActionLabel} ({addShortcutLabel})3395 {isCloneDestinationStep && !canCloneProject
3396 ? "This connection needs permission to write source control and add projects."
3397 : canCreateProject
3398 ? `${submitActionLabel} (${addShortcutLabel})`
3399 : "This connection cannot add projects."}
3359 </TooltipPopup>3400 </TooltipPopup>
3360 </Tooltip>3401 </Tooltip>
3361 ) : null;3402 ) : null;
apps/web/src/components/GitActionsControl.test.ts created+295
...@@ -0,0 +1,295 @@
1import {
2 AuthOrchestrationOperateScope,
3 AuthSourceControlWriteScope,
4 EnvironmentId,
5 ThreadId,
6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import { AsyncResult } from "effect/reactivity";
9import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
10
11const state = vi.hoisted(() => ({
12 scopes: new Set<string>(),
13 primaryScopes: new Set<string>(),
14 shell: { branch: "main" } as { branch: string } | null,
15 detail: null as { branch: string } | null,
16 draft: null as { branch: string; worktreePath: null; envMode: "local" } | null,
17 branch: "main",
18 commits: 0,
19 metadataRequests: [] as { environmentId: string; branch: string }[],
20 afterGitAction: undefined as (() => void) | undefined,
21 run: null as ((input: { action: "commit"; featureBranch?: boolean }) => Promise<void>) | null,
22}));
23
24vi.mock("react", async (importOriginal) => ({
25 ...(await importOriginal<typeof import("react")>()),
26 useCallback: (callback: unknown) => callback,
27 useMemo: (factory: () => unknown) => factory(),
28 useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}],
29 useRef: (current: unknown) => ({ current }),
30 useEffect: () => {},
31 useEffectEvent: (callback: typeof state.run) => {
32 state.run = callback;
33 return callback;
34 },
35}));
36vi.mock("@effect/atom-react", () => ({
37 useAtomValue: (atom: unknown) => (atom === "vcs-state" ? { isRunning: false } : null),
38}));
39vi.mock("~/state/entities", () => ({
40 useThreadProjection: (ref: unknown) =>
41 ref === null || state.detail === null ? null : { projection: { thread: state.detail } },
42 useThreadShell: () => state.shell,
43}));
44vi.mock("~/state/session", () => ({
45 useEnvironmentScope: (environmentId: unknown, scope: string) =>
46 (environmentId === "environment" ? state.scopes : state.primaryScopes).has(scope),
47 readEnvironmentScope: (environmentId: unknown, scope: string) =>
48 (environmentId === "environment" ? state.scopes : state.primaryScopes).has(scope),
49}));
50vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
51vi.mock("~/state/server", () => ({ serverEnvironment: { configValueAtom: () => null } }));
52vi.mock("~/state/sourceControl", () => ({ sourceControlEnvironment: {} }));
53vi.mock("~/state/vcs", () => ({
54 vcsEnvironment: { status: () => null },
55 vcsActionManager: { stateAtom: () => "vcs-state" },
56}));
57vi.mock("~/state/threads", () => ({
58 threadEnvironment: {
59 updateMetadata: async ({
60 environmentId,
61 input,
62 }: {
63 environmentId: string;
64 input: { branch: string };
65 }) => {
66 state.metadataRequests.push({ environmentId, branch: input.branch });
67 if (!state.scopes.has(AuthOrchestrationOperateScope))
68 return AsyncResult.failure(Cause.fail(new Error("Task denied")));
69 if (state.shell) state.shell.branch = input.branch;
70 if (state.detail) state.detail.branch = input.branch;
71 return AsyncResult.success(undefined);
72 },
73 },
74}));
75vi.mock("~/state/query", () => ({
76 useEnvironmentQuery: () => ({
77 data: {
78 isRepo: true,
79 refName: "main",
80 isDefaultRef: false,
81 hasPrimaryRemote: true,
82 hasWorkingTreeChanges: true,
83 workingTree: { files: [{ path: "file.ts", status: "modified" }] },
84 },
85 error: null,
86 }),
87}));
88vi.mock("~/composerDraftStore", () => ({
89 useComposerDraftStore: (
90 select: (store: {
91 getDraftSession: () => typeof state.draft;
92 getDraftThreadByRef: () => typeof state.draft;
93 setDraftThreadContext: (_target: unknown, input: { branch: string }) => void;
94 }) => unknown,
95 ) =>
96 select({
97 getDraftSession: () => state.draft,
98 getDraftThreadByRef: () => state.draft,
99 setDraftThreadContext: (_target, input) => {
100 if (state.draft) state.draft.branch = input.branch;
101 },
102 }),
103}));
104vi.mock("~/lib/sourceControlActions", () => ({
105 useSourceControlActionRunning: () => false,
106 useVcsInitAction: () => ({}),
107 useVcsPullAction: () => ({}),
108 useSourceControlPublishRepositoryAction: () => ({}),
109 useGitStackedAction: () => ({
110 run: async ({ featureBranch }: { featureBranch?: boolean }) => {
111 state.commits += 1;
112 if (featureBranch) state.branch = "feature";
113 state.afterGitAction?.();
114 return {
115 _tag: "Success",
116 value: {
117 branch: featureBranch
118 ? { status: "created", name: "feature" }
119 : { status: "skipped_not_requested" },
120 toast: { title: "Committed", description: "Committed", cta: { kind: "none" } },
121 },
122 };
123 },
124 }),
125}));
126vi.mock("~/lib/utils", () => ({ cn: () => "", randomUUID: () => "action" }));
127vi.mock("~/editorPreferences", () => ({ useOpenInPreferredEditor: () => () => {} }));
128vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => () => {} }));
129vi.mock("~/lib/openPullRequestLink", () => ({ useOpenPrLink: () => () => {} }));
130vi.mock("~/components/ui/toast", () => ({
131 stackedThreadToast: (input: unknown) => input,
132 toastManager: { add: () => "toast", update: () => {}, close: () => {} },
133}));
134vi.mock("~/components/ui/dialog", () => ({
135 Dialog: "Dialog",
136 DialogDescription: "DialogDescription",
137 DialogFooter: "DialogFooter",
138 DialogHeader: "DialogHeader",
139 DialogPanel: "DialogPanel",
140 DialogPopup: "DialogPopup",
141 DialogTitle: "DialogTitle",
142}));
143vi.mock("~/components/ui/group", () => ({ Group: "Group", GroupSeparator: "GroupSeparator" }));
144vi.mock("~/components/ui/menu", () => ({
145 Menu: "Menu",
146 MenuItem: "MenuItem",
147 MenuItemLabel: "MenuItemLabel",
148 MenuPopup: "MenuPopup",
149 MenuTrigger: "MenuTrigger",
150}));
151vi.mock("~/components/ui/popover", () => ({
152 Popover: "Popover",
153 PopoverPopup: "PopoverPopup",
154 PopoverTrigger: "PopoverTrigger",
155}));
156vi.mock("~/components/ui/tooltip", () => ({
157 Tooltip: "Tooltip",
158 TooltipPopup: "TooltipPopup",
159 TooltipTrigger: "TooltipTrigger",
160}));
161vi.mock("~/components/ui/button", () => ({ Button: "Button" }));
162vi.mock("~/components/ui/checkbox", () => ({ Checkbox: "Checkbox" }));
163vi.mock("~/components/ui/input", () => ({ Input: "Input" }));
164vi.mock("~/components/ui/radio-group", () => ({ RadioGroup: "RadioGroup" }));
165vi.mock("~/components/ui/scroll-area", () => ({ ScrollArea: "ScrollArea" }));
166vi.mock("~/components/ui/spinner", () => ({ Spinner: "Spinner" }));
167vi.mock("~/components/ui/textarea", () => ({ Textarea: "Textarea" }));
168vi.mock("~/components/ui/toggle", () => ({ toggleVariants: () => "" }));
169vi.mock("./AnimatedHeight", () => ({ AnimatedHeight: "AnimatedHeight" }));
170
171import GitActionsControl from "./GitActionsControl";
172
173function renderActions() {
174 GitActionsControl({
175 gitCwd: "/repo",
176 activeThreadRef: {
177 environmentId: EnvironmentId.make("environment"),
178 threadId: ThreadId.make("thread"),
179 },
180 });
181 if (!state.run) throw new Error("Git action missing");
182 return state.run;
183}
184
185describe("Git actions while thread details load", () => {
186 beforeEach(() => {
187 state.scopes = new Set([AuthSourceControlWriteScope]);
188 state.primaryScopes = new Set([AuthSourceControlWriteScope, AuthOrchestrationOperateScope]);
189 state.shell = { branch: "main" };
190 state.detail = null;
191 state.draft = null;
192 state.branch = "main";
193 state.commits = 0;
194 state.metadataRequests = [];
195 state.afterGitAction = undefined;
196 state.run = null;
197 });
198
199 it("does not create a feature branch for a server thread without task permission", async () => {
200 await renderActions()({ action: "commit", featureBranch: true });
201
202 expect(state.branch).toBe("main");
203 expect(state.commits).toBe(0);
204 expect(state.shell?.branch).toBe("main");
205 });
206
207 it("commits and synchronizes the server thread before details finish loading", async () => {
208 state.primaryScopes.clear();
209 state.scopes.add(AuthOrchestrationOperateScope);
210 await renderActions()({ action: "commit", featureBranch: true });
211
212 expect(state.branch).toBe("feature");
213 expect(state.commits).toBe(1);
214 expect(state.shell?.branch).toBe("feature");
215 expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]);
216 });
217
218 it("synchronizes archived thread detail after its shell disappears", async () => {
219 state.scopes.add(AuthOrchestrationOperateScope);
220 state.detail = { branch: "main" };
221 renderActions();
222 state.shell = null;
223
224 await renderActions()({ action: "commit", featureBranch: true });
225
226 expect(state.commits).toBe(1);
227 expect(state.branch).toBe("feature");
228 expect(state.detail.branch).toBe("feature");
229 expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]);
230 });
231
232 it("requires task permission before changing an archived thread's branch", async () => {
233 state.shell = null;
234 state.detail = { branch: "main" };
235
236 await renderActions()({ action: "commit", featureBranch: true });
237
238 expect(state.commits).toBe(0);
239 expect(state.detail.branch).toBe("main");
240 expect(state.metadataRequests).toEqual([]);
241 });
242
243 it("uses the current shell branch when cached detail names the new branch", async () => {
244 state.scopes.add(AuthOrchestrationOperateScope);
245 state.detail = { branch: "feature" };
246
247 await renderActions()({ action: "commit", featureBranch: true });
248
249 expect(state.commits).toBe(1);
250 expect(state.shell?.branch).toBe("feature");
251 expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]);
252 });
253
254 it("synchronizes a retained callback after the thread grant is gained", async () => {
255 const run = renderActions();
256 state.scopes.add(AuthOrchestrationOperateScope);
257 await run({ action: "commit", featureBranch: true });
258 expect(state.commits).toBe(1);
259 expect(state.shell?.branch).toBe("feature");
260 expect(state.metadataRequests).toHaveLength(1);
261 });
262
263 it.each([
264 [AuthOrchestrationOperateScope, false],
265 [AuthSourceControlWriteScope, true],
266 ] as const)(
267 "uses the fresh task grant after Git finishes, revoking %s",
268 async (revokedScope, syncsThread) => {
269 state.scopes.add(AuthOrchestrationOperateScope);
270 state.afterGitAction = () => state.scopes.delete(revokedScope);
271 await renderActions()({ action: "commit", featureBranch: true });
272 expect(state.commits).toBe(1);
273 expect(state.branch).toBe("feature");
274 expect(state.shell?.branch).toBe(syncsThread ? "feature" : "main");
275 expect(state.metadataRequests).toHaveLength(syncsThread ? 1 : 0);
276 },
277 );
278
279 it("keeps ordinary commits available while details load", async () => {
280 await renderActions()({ action: "commit" });
281
282 expect(state.commits).toBe(1);
283 expect(state.branch).toBe("main");
284 });
285
286 it("keeps feature-branch commits available for a local draft", async () => {
287 state.shell = null;
288 state.draft = { branch: "main", worktreePath: null, envMode: "local" };
289 await renderActions()({ action: "commit", featureBranch: true });
290
291 expect(state.commits).toBe(1);
292 expect(state.draft.branch).toBe("feature");
293 expect(state.metadataRequests).toEqual([]);
294 });
295});
apps/web/src/components/GitActionsControl.tsx+61-17
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";1import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import { type ScopedThreadRef } from "@t3tools/contracts";3import {
4 AuthOrchestrationOperateScope,
5 AuthSourceControlWriteScope,
6 type ScopedThreadRef,
7} from "@t3tools/contracts";
4import {8import {
5 isAtomCommandInterrupted,9 isAtomCommandInterrupted,
6 squashAtomCommandFailure,10 squashAtomCommandFailure,
...@@ -108,7 +112,8 @@ import {...@@ -108,7 +112,8 @@ import {
108 useVcsInitAction,112 useVcsInitAction,
109 useVcsPullAction,113 useVcsPullAction,
110} from "~/lib/sourceControlActions";114} from "~/lib/sourceControlActions";
111import { useThreadShell } from "~/state/entities";115import { useThreadProjection, useThreadShell } from "~/state/entities";
116import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
112import { useEnvironmentQuery } from "~/state/query";117import { useEnvironmentQuery } from "~/state/query";
113import { serverEnvironment } from "~/state/server";118import { serverEnvironment } from "~/state/server";
114import { sourceControlEnvironment } from "~/state/sourceControl";119import { sourceControlEnvironment } from "~/state/sourceControl";
...@@ -630,14 +635,19 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -630,14 +635,19 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
630 ] as const;635 ] as const;
631636
632 const canSubmitPublishRepository = useMemo(() => {637 const canSubmitPublishRepository = useMemo(() => {
633 if (!selectedPublishProviderReadiness.ready) return false;638 if (!publishRepositoryAction.isAllowed || !selectedPublishProviderReadiness.ready) return false;
634 if (publishRepositoryAction.isPending) return false;639 if (publishRepositoryAction.isPending) return false;
635 const repositoryParts = publishRepository.trim().split("/");640 const repositoryParts = publishRepository.trim().split("/");
636 const owner = repositoryParts[0]?.trim() ?? "";641 const owner = repositoryParts[0]?.trim() ?? "";
637 const rest = repositoryParts.slice(1);642 const rest = repositoryParts.slice(1);
638 const name = rest.join("/").trim();643 const name = rest.join("/").trim();
639 return owner.length > 0 && name.length > 0;644 return owner.length > 0 && name.length > 0;
640 }, [publishRepository, publishRepositoryAction.isPending, selectedPublishProviderReadiness]);645 }, [
646 publishRepository,
647 publishRepositoryAction.isAllowed,
648 publishRepositoryAction.isPending,
649 selectedPublishProviderReadiness,
650 ]);
641651
642 const submitPublishRepository = useCallback(() => {652 const submitPublishRepository = useCallback(() => {
643 if (!canSubmitPublishRepository) {653 if (!canSubmitPublishRepository) {
...@@ -1070,6 +1080,11 @@ export default function GitActionsControl({...@@ -1070,6 +1080,11 @@ export default function GitActionsControl({
1070 );1080 );
1071 const activeEnvironmentId = activeThreadRef?.environmentId ?? null;1081 const activeEnvironmentId = activeThreadRef?.environmentId ?? null;
1072 const successScopeKey = `${activeEnvironmentId ?? ""}\u0000${gitCwd ?? ""}`;1082 const successScopeKey = `${activeEnvironmentId ?? ""}\u0000${gitCwd ?? ""}`;
1083 const canWriteSourceControl = useEnvironmentScope(
1084 activeEnvironmentId,
1085 AuthSourceControlWriteScope,
1086 );
1087 const canOperateThread = useEnvironmentScope(activeEnvironmentId, AuthOrchestrationOperateScope);
1073 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(activeEnvironmentId));1088 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(activeEnvironmentId));
1074 const openInPreferredEditor = useOpenInPreferredEditor(1089 const openInPreferredEditor = useOpenInPreferredEditor(
1075 activeEnvironmentId,1090 activeEnvironmentId,
...@@ -1079,7 +1094,7 @@ export default function GitActionsControl({...@@ -1079,7 +1094,7 @@ export default function GitActionsControl({
1079 () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined),1094 () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined),
1080 [activeThreadRef],1095 [activeThreadRef],
1081 );1096 );
1082 const activeServerThread = useThreadShell(activeThreadRef);1097 const activeServerThreadShell = useThreadShell(activeThreadRef);
1083 const openPrLink = useOpenPrLink(activeThreadRef ?? undefined);1098 const openPrLink = useOpenPrLink(activeThreadRef ?? undefined);
1084 const activeDraftThread = useComposerDraftStore((store) =>1099 const activeDraftThread = useComposerDraftStore((store) =>
1085 draftId1100 draftId
...@@ -1088,6 +1103,13 @@ export default function GitActionsControl({...@@ -1088,6 +1103,13 @@ export default function GitActionsControl({
1088 ? store.getDraftThreadByRef(activeThreadRef)1103 ? store.getDraftThreadByRef(activeThreadRef)
1089 : null,1104 : null,
1090 );1105 );
1106 const activeServerThreadProjection = useThreadProjection(
1107 activeDraftThread !== null && activeServerThreadShell === null ? null : activeThreadRef,
1108 );
1109 const activeServerThread =
1110 activeServerThreadShell ?? activeServerThreadProjection?.projection.thread ?? null;
1111 const isLocalDraftThread = activeDraftThread !== null && activeServerThread === null;
1112 const canChangeThreadBranch = canWriteSourceControl && (isLocalDraftThread || canOperateThread);
1091 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);1113 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
1092 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);1114 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
1093 const [dialogCommitMessage, setDialogCommitMessage] = useState("");1115 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
...@@ -1120,7 +1142,10 @@ export default function GitActionsControl({...@@ -1120,7 +1142,10 @@ export default function GitActionsControl({
1120 }1142 }
11211143
1122 if (activeServerThread) {1144 if (activeServerThread) {
1123 if (activeServerThread.branch === branch) {1145 if (
1146 !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) ||
1147 activeServerThread.branch === branch
1148 ) {
1124 return;1149 return;
1125 }1150 }
11261151
...@@ -1248,9 +1273,11 @@ export default function GitActionsControl({...@@ -1248,9 +1273,11 @@ export default function GitActionsControl({
1248 resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote),1273 resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote),
1249 [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning],1274 [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning],
1250 );1275 );
1251 const quickActionDisabledReason = quickAction.disabled1276 const quickActionDisabledReason = !canWriteSourceControl
1252 ? (quickAction.hint ?? "This action is currently unavailable.")1277 ? "This connection cannot change source control."
1253 : null;1278 : quickAction.disabled
1279 ? (quickAction.hint ?? "This action is currently unavailable.")
1280 : null;
1254 const gitActionProgress = resolveGitActionProgressPresentation(vcsActionState);1281 const gitActionProgress = resolveGitActionProgressPresentation(vcsActionState);
1255 const pendingDefaultBranchActionCopy = pendingDefaultBranchAction1282 const pendingDefaultBranchActionCopy = pendingDefaultBranchAction
1256 ? resolveDefaultBranchActionDialogCopy({1283 ? resolveDefaultBranchActionDialogCopy({
...@@ -1304,6 +1331,14 @@ export default function GitActionsControl({...@@ -1304,6 +1331,14 @@ export default function GitActionsControl({
1304 featureBranch = false,1331 featureBranch = false,
1305 filePaths,1332 filePaths,
1306 }: RunGitActionWithToastInput) => {1333 }: RunGitActionWithToastInput) => {
1334 if (
1335 activeEnvironmentId === null ||
1336 !readEnvironmentScope(activeEnvironmentId, AuthSourceControlWriteScope) ||
1337 (featureBranch &&
1338 !isLocalDraftThread &&
1339 !readEnvironmentScope(activeEnvironmentId, AuthOrchestrationOperateScope))
1340 )
1341 return;
1307 const actionStatus = statusOverride ?? gitStatusForActions;1342 const actionStatus = statusOverride ?? gitStatusForActions;
1308 const actionBranch = actionStatus?.refName ?? null;1343 const actionBranch = actionStatus?.refName ?? null;
1309 const actionIsDefaultBranch = featureBranch ? false : isDefaultRef;1344 const actionIsDefaultBranch = featureBranch ? false : isDefaultRef;
...@@ -1459,7 +1494,7 @@ export default function GitActionsControl({...@@ -1459,7 +1494,7 @@ export default function GitActionsControl({
1459 };1494 };
14601495
1461 const checkoutFeatureBranchAndContinuePendingAction = () => {1496 const checkoutFeatureBranchAndContinuePendingAction = () => {
1462 if (!pendingDefaultBranchAction) return;1497 if (!canChangeThreadBranch || !pendingDefaultBranchAction) return;
1463 const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction;1498 const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction;
1464 setPendingDefaultBranchAction(null);1499 setPendingDefaultBranchAction(null);
1465 void runGitActionWithToast({1500 void runGitActionWithToast({
...@@ -1473,7 +1508,7 @@ export default function GitActionsControl({...@@ -1473,7 +1508,7 @@ export default function GitActionsControl({
1473 };1508 };
14741509
1475 const runDialogActionOnNewBranch = () => {1510 const runDialogActionOnNewBranch = () => {
1476 if (!isCommitDialogOpen) return;1511 if (!canChangeThreadBranch || !isCommitDialogOpen) return;
1477 const commitMessage = dialogCommitMessage.trim();1512 const commitMessage = dialogCommitMessage.trim();
14781513
1479 setIsCommitDialogOpen(false);1514 setIsCommitDialogOpen(false);
...@@ -1616,7 +1651,8 @@ export default function GitActionsControl({...@@ -1616,7 +1651,8 @@ export default function GitActionsControl({
1616 [gitCwd, openInPreferredEditor, threadToastData],1651 [gitCwd, openInPreferredEditor, threadToastData],
1617 );1652 );
16181653
1619 const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote;1654 const canPublishRepository =
1655 canWriteSourceControl && isRepo && gitStatusForActions !== null && !hasPrimaryRemote;
16201656
1621 const initializeGit = () => {1657 const initializeGit = () => {
1622 void (async () => {1658 void (async () => {
...@@ -1683,7 +1719,7 @@ export default function GitActionsControl({...@@ -1683,7 +1719,7 @@ export default function GitActionsControl({
1683 <MenuItem1719 <MenuItem
1684 density={presentation === "menu" ? "touch" : "default"}1720 density={presentation === "menu" ? "touch" : "default"}
1685 key={`${item.id}-${item.label}`}1721 key={`${item.id}-${item.label}`}
1686 disabled={item.disabled}1722 disabled={!canWriteSourceControl || item.disabled}
1687 onClick={() => {1723 onClick={() => {
1688 openDialogForMenuItem(item);1724 openDialogForMenuItem(item);
1689 }}1725 }}
...@@ -1777,7 +1813,7 @@ export default function GitActionsControl({...@@ -1777,7 +1813,7 @@ export default function GitActionsControl({
1777 variant={isPanel ? "ghost" : "outline"}1813 variant={isPanel ? "ghost" : "outline"}
1778 part="row"1814 part="row"
1779 panel={isPanel}1815 panel={isPanel}
1780 disabled={initAction.isPending}1816 disabled={!canWriteSourceControl || initAction.isPending}
1781 onClick={initializeGit}1817 onClick={initializeGit}
1782 >1818 >
1783 <GitBranchPlusIcon className="size-3.5" aria-hidden />1819 <GitBranchPlusIcon className="size-3.5" aria-hidden />
...@@ -1855,7 +1891,7 @@ export default function GitActionsControl({...@@ -1855,7 +1891,7 @@ export default function GitActionsControl({
1855 size="xs"1891 size="xs"
1856 part="primary"1892 part="primary"
1857 panel={isPanel}1893 panel={isPanel}
1858 disabled={isGitActionRunning || quickAction.disabled}1894 disabled={!canWriteSourceControl || isGitActionRunning || quickAction.disabled}
1859 onClick={runQuickAction}1895 onClick={runQuickAction}
1860 >1896 >
1861 <GitQuickActionIcon1897 <GitQuickActionIcon
...@@ -2035,6 +2071,8 @@ export default function GitActionsControl({...@@ -2035,6 +2071,8 @@ export default function GitActionsControl({
2035 <button2071 <button
2036 type="button"2072 type="button"
2037 className="flex min-w-0 flex-1 items-center justify-between gap-3 text-left"2073 className="flex min-w-0 flex-1 items-center justify-between gap-3 text-left"
2074 disabled={!canOperateThread}
2075 aria-label={`Open ${file.path} in editor`}
2038 onClick={() => openChangedFileInEditor(file.path)}2076 onClick={() => openChangedFileInEditor(file.path)}
2039 >2077 >
2040 <StartTruncatedPath2078 <StartTruncatedPath
...@@ -2102,12 +2140,16 @@ export default function GitActionsControl({...@@ -2102,12 +2140,16 @@ export default function GitActionsControl({
2102 <Button2140 <Button
2103 variant="outline"2141 variant="outline"
2104 size="sm"2142 size="sm"
2105 disabled={noneSelected}2143 disabled={!canChangeThreadBranch || noneSelected}
2106 onClick={runDialogActionOnNewBranch}2144 onClick={runDialogActionOnNewBranch}
2107 >2145 >
2108 Commit on new branch2146 Commit on new branch
2109 </Button>2147 </Button>
2110 <Button size="sm" disabled={noneSelected} onClick={runDialogAction}>2148 <Button
2149 size="sm"
2150 disabled={!canWriteSourceControl || noneSelected}
2151 onClick={runDialogAction}
2152 >
2111 Commit2153 Commit
2112 </Button>2154 </Button>
2113 </DialogFooter>2155 </DialogFooter>
...@@ -2151,6 +2193,7 @@ export default function GitActionsControl({...@@ -2151,6 +2193,7 @@ export default function GitActionsControl({
2151 variant="outline"2193 variant="outline"
2152 size="sm-multiline"2194 size="sm-multiline"
2153 onClick={continuePendingDefaultBranchAction}2195 onClick={continuePendingDefaultBranchAction}
2196 disabled={!canWriteSourceControl}
2154 >2197 >
2155 {pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"}2198 {pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"}
2156 </Button>2199 </Button>
...@@ -2158,6 +2201,7 @@ export default function GitActionsControl({...@@ -2158,6 +2201,7 @@ export default function GitActionsControl({
2158 className="w-full max-w-full sm:w-auto"2201 className="w-full max-w-full sm:w-auto"
2159 size="sm-multiline"2202 size="sm-multiline"
2160 onClick={checkoutFeatureBranchAndContinuePendingAction}2203 onClick={checkoutFeatureBranchAndContinuePendingAction}
2204 disabled={!canChangeThreadBranch}
2161 >2205 >
2162 Check out feature branch & continue2206 Check out feature branch & continue
2163 </Button>2207 </Button>
apps/web/src/components/PullRequestThreadDialog.test.ts created+273
...@@ -0,0 +1,273 @@
1import {
2 AuthOrchestrationOperateScope,
3 AuthSourceControlWriteScope,
4 EnvironmentId,
5 ThreadId,
6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import { AsyncResult } from "effect/reactivity";
9import { isValidElement, type ReactNode } from "react";
10import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
11
12const state = vi.hoisted(() => ({
13 canOperate: false,
14 canWriteSourceControl: true,
15 hookValues: [] as unknown[],
16 hookIndex: 0,
17 tracks: 0,
18 resets: 0,
19 manager: { operation: null as string | null, error: null, isRunning: false },
20 interrupted: false,
21 pendingResponse: undefined as Promise<void> | undefined,
22 openChanges: [] as boolean[],
23 checkouts: [] as string[],
24 setupScripts: 0,
25 prepared: [] as { branch: string; worktreePath: string | null }[],
26}));
27
28vi.mock("react", async (importOriginal) => ({
29 ...(await importOriginal<typeof import("react")>()),
30 useCallback: (callback: unknown) => callback,
31 useMemo: (factory: () => unknown) => factory(),
32 useState: (initial: unknown) => {
33 const index = state.hookIndex++;
34 if (index === state.hookValues.length) state.hookValues.push(initial);
35 return [state.hookValues[index], (value: unknown) => (state.hookValues[index] = value)];
36 },
37 useRef: (current: unknown) => ({ current }),
38 useEffect: () => {},
39}));
40vi.mock("@tanstack/react-pacer", () => ({
41 useDebouncedValue: (value: unknown) => [value, { state: { isPending: false } }],
42}));
43vi.mock("~/state/session", () => ({
44 useEnvironmentScope: (_environmentId: unknown, scope: string) =>
45 scope === AuthOrchestrationOperateScope
46 ? state.canOperate
47 : scope === AuthSourceControlWriteScope && state.canWriteSourceControl,
48 readEnvironmentScope: (_environmentId: unknown, scope: string) =>
49 scope === AuthOrchestrationOperateScope
50 ? state.canOperate
51 : scope === AuthSourceControlWriteScope && state.canWriteSourceControl,
52}));
53vi.mock("~/lib/sourceControlActions", async () => {
54 const { usePreparePullRequestThreadAction } = await vi.importActual<
55 typeof import("~/state/sourceControlActions")
56 >("~/state/sourceControlActions");
57 return {
58 readCachedPullRequestResolution: () => null,
59 usePullRequestResolution: () => ({
60 data: { pullRequest: { number: 123, title: "Pull request", state: "open" } },
61 }),
62 usePreparePullRequestThreadAction,
63 };
64});
65vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.manager }));
66vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: {} }));
67vi.mock("~/state/use-atom-command", () => ({
68 useAtomCommand: (command: unknown) => command,
69}));
70vi.mock("~/state/sourceControl", () => ({ sourceControlEnvironment: {} }));
71vi.mock("~/state/git", () => ({
72 gitEnvironment: {
73 preparePullRequestThread: async ({
74 input: { mode, threadId },
75 }: {
76 input: { mode: string; threadId?: string };
77 }) => {
78 state.checkouts.push(mode);
79 if (mode === "worktree" && threadId) state.setupScripts += 1;
80 await state.pendingResponse;
81 if (state.interrupted) return AsyncResult.failure(Cause.interrupt());
82 return AsyncResult.success({
83 branch: "feature/pr",
84 worktreePath: mode === "worktree" ? "/worktree" : null,
85 });
86 },
87 },
88}));
89vi.mock("~/lib/utils", () => ({ cn: () => "" }));
90vi.mock("~/state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) }));
91vi.mock("~/state/vcs", () => ({
92 vcsEnvironment: { status: () => null },
93 vcsActionManager: {
94 stateAtom: () => "vcs-state",
95 track: (_registry: unknown, _target: unknown, _input: unknown, execute: () => unknown) => {
96 state.tracks += 1;
97 return execute();
98 },
99 resetError: () => state.resets++,
100 },
101}));
102vi.mock("./ui/button", () => ({ Button: "Button" }));
103vi.mock("./ui/input", () => ({ Input: "Input" }));
104vi.mock("./ui/spinner", () => ({ Spinner: "Spinner" }));
105vi.mock("./ui/dialog", () => ({
106 Dialog: "Dialog",
107 DialogDescription: "DialogDescription",
108 DialogFooter: "DialogFooter",
109 DialogHeader: "DialogHeader",
110 DialogPanel: "DialogPanel",
111 DialogPopup: "DialogPopup",
112 DialogTitle: "DialogTitle",
113}));
114
115import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
116
117function findAction(node: ReactNode, label: string): (() => unknown) | null {
118 if (Array.isArray(node)) {
119 for (const child of node) {
120 const action = findAction(child, label);
121 if (action) return action;
122 }
123 return null;
124 }
125 if (!isValidElement<{ children?: ReactNode; onClick?: () => unknown }>(node)) return null;
126 if (node.props.children === label) return node.props.onClick ?? null;
127 return findAction(node.props.children, label);
128}
129
130function renderDialog() {
131 state.hookIndex = 0;
132 return PullRequestThreadDialog({
133 open: true,
134 environmentId: EnvironmentId.make("environment"),
135 threadId: ThreadId.make("thread"),
136 cwd: "/repo",
137 initialReference: "123",
138 onOpenChange: (open) => state.openChanges.push(open),
139 onPrepared: (input) => {
140 state.prepared.push(input);
141 },
142 });
143}
144
145function prepareAction(label: "Local" | "Worktree") {
146 const action = findAction(renderDialog(), label);
147 if (!action) throw new Error(`${label} action missing`);
148 return action;
149}
150
151function visibleText(node: ReactNode): string {
152 if (Array.isArray(node)) return node.map(visibleText).join(" ");
153 if (typeof node === "string" || typeof node === "number") return String(node);
154 return isValidElement<{ children?: ReactNode }>(node) ? visibleText(node.props.children) : "";
155}
156
157function deferred<T>() {
158 let resolve!: (value: T) => void;
159 const promise = new Promise<T>((complete) => {
160 resolve = complete;
161 });
162 return { promise, resolve };
163}
164
165describe("pull request worktree permissions", () => {
166 beforeEach(() => {
167 state.canOperate = false;
168 state.canWriteSourceControl = true;
169 state.hookValues = [];
170 state.hookIndex = 0;
171 state.tracks = 0;
172 state.resets = 0;
173 state.manager = { operation: null, error: null, isRunning: false };
174 state.interrupted = false;
175 state.pendingResponse = undefined;
176 state.openChanges = [];
177 state.checkouts = [];
178 state.setupScripts = 0;
179 state.prepared = [];
180 });
181
182 it("does not prepare a worktree or run setup with only source-control permission", async () => {
183 await prepareAction("Worktree")();
184
185 expect(state.checkouts).toEqual([]);
186 expect(state.setupScripts).toBe(0);
187 expect(state.prepared).toEqual([]);
188 });
189
190 it("prepares the worktree and thread when task permission is also granted", async () => {
191 state.canOperate = true;
192 await prepareAction("Worktree")();
193
194 expect(state.checkouts).toEqual(["worktree"]);
195 expect(state.setupScripts).toBe(1);
196 expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: "/worktree" }]);
197 });
198
199 it("rechecks task permission before invoking a retained worktree action", async () => {
200 state.canOperate = true;
201 const prepare = prepareAction("Worktree");
202 state.canOperate = false;
203 await prepare();
204
205 expect(state.checkouts).toEqual([]);
206 expect(state.setupScripts).toBe(0);
207 });
208
209 it("keeps local checkout available without task permission", async () => {
210 await prepareAction("Local")();
211
212 expect(state.checkouts).toEqual(["local"]);
213 expect(state.setupScripts).toBe(0);
214 expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: null }]);
215 });
216
217 it.each(["Local", "Worktree"] as const)(
218 "shows the returned scope denial for a retained %s callback without starting a checkout",
219 async (label) => {
220 state.canOperate = true;
221 state.manager = { operation: "pull", error: null, isRunning: true };
222 const prepare = prepareAction(label);
223 state.canWriteSourceControl = false;
224
225 await prepare();
226
227 expect(visibleText(renderDialog())).toContain(
228 "This connection cannot change source control.",
229 );
230 expect(state.checkouts).toEqual([]);
231 expect(state.setupScripts).toBe(0);
232 expect(state.prepared).toEqual([]);
233 expect(state.openChanges).toEqual([]);
234 expect(state.tracks).toBe(0);
235 expect(state.manager).toEqual({ operation: "pull", error: null, isRunning: true });
236 },
237 );
238
239 it("clears a returned error when a newly authorized retry starts, then completes normally", async () => {
240 const prepare = prepareAction("Local");
241 state.canWriteSourceControl = false;
242 await prepare();
243 expect(visibleText(renderDialog())).toContain("This connection cannot change source control.");
244
245 state.canWriteSourceControl = true;
246 const response = deferred<void>();
247 state.pendingResponse = response.promise;
248 const completion = prepareAction("Local")();
249
250 expect(visibleText(renderDialog())).not.toContain(
251 "This connection cannot change source control.",
252 );
253 expect(state.checkouts).toEqual(["local"]);
254 expect(state.prepared).toEqual([]);
255 response.resolve(undefined);
256 await completion;
257
258 expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: null }]);
259 expect(state.openChanges).toEqual([false]);
260 });
261
262 it("keeps interrupted preparations quiet and preserves error reset behavior", async () => {
263 state.interrupted = true;
264 const before = visibleText(renderDialog());
265
266 await prepareAction("Local")();
267
268 expect(visibleText(renderDialog())).toBe(before);
269 expect(state.resets).toBe(1);
270 expect(state.prepared).toEqual([]);
271 expect(state.openChanges).toEqual([]);
272 });
273});
apps/web/src/components/PullRequestThreadDialog.tsx+35-8
...@@ -1,5 +1,12 @@...@@ -1,5 +1,12 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";1import {
2import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";2 AuthOrchestrationOperateScope,
3 type EnvironmentId,
4 type ThreadId,
5} from "@t3tools/contracts";
6import {
7 isAtomCommandInterrupted,
8 squashAtomCommandFailure,
9} from "@t3tools/client-runtime/state/runtime";
3import { useDebouncedValue } from "@tanstack/react-pacer";10import { useDebouncedValue } from "@tanstack/react-pacer";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";11import { useCallback, useEffect, useMemo, useRef, useState } from "react";
512
...@@ -12,6 +19,7 @@ import { cn } from "~/lib/utils";...@@ -12,6 +19,7 @@ import { cn } from "~/lib/utils";
12import { parsePullRequestReference } from "~/pullRequestReference";19import { parsePullRequestReference } from "~/pullRequestReference";
13import { getSourceControlPresentation } from "~/sourceControlPresentation";20import { getSourceControlPresentation } from "~/sourceControlPresentation";
14import { useEnvironmentQuery } from "~/state/query";21import { useEnvironmentQuery } from "~/state/query";
22import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
15import { vcsEnvironment } from "~/state/vcs";23import { vcsEnvironment } from "~/state/vcs";
16import { Button } from "./ui/button";24import { Button } from "./ui/button";
17import {25import {
...@@ -49,6 +57,7 @@ export function PullRequestThreadDialog({...@@ -49,6 +57,7 @@ export function PullRequestThreadDialog({
49 const [reference, setReference] = useState(initialReference ?? "");57 const [reference, setReference] = useState(initialReference ?? "");
50 const [referenceDirty, setReferenceDirty] = useState(false);58 const [referenceDirty, setReferenceDirty] = useState(false);
51 const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null);59 const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null);
60 const [prepareErrorMessage, setPrepareErrorMessage] = useState<string | null>(null);
52 const [debouncedReference, referenceDebouncer] = useDebouncedValue(61 const [debouncedReference, referenceDebouncer] = useDebouncedValue(
53 reference,62 reference,
54 { wait: 450 },63 { wait: 450 },
...@@ -102,6 +111,7 @@ export function PullRequestThreadDialog({...@@ -102,6 +111,7 @@ export function PullRequestThreadDialog({
102 );111 );
103 }, [parsedReference, sourceControlScope]);112 }, [parsedReference, sourceControlScope]);
104 const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope);113 const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope);
114 const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
105115
106 const liveResolvedPullRequest =116 const liveResolvedPullRequest =
107 parsedReference !== null && parsedReference === parsedDebouncedReference117 parsedReference !== null && parsedReference === parsedDebouncedReference
...@@ -131,6 +141,13 @@ export function PullRequestThreadDialog({...@@ -131,6 +141,13 @@ export function PullRequestThreadDialog({
131141
132 const handleConfirm = useCallback(142 const handleConfirm = useCallback(
133 async (mode: "local" | "worktree") => {143 async (mode: "local" | "worktree") => {
144 if (!preparePullRequestThreadAction.isAllowed) return;
145 if (
146 mode === "worktree" &&
147 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
148 ) {
149 return;
150 }
134 if (!parsedReference) {151 if (!parsedReference) {
135 setReferenceDirty(true);152 setReferenceDirty(true);
136 return;153 return;
...@@ -138,6 +155,7 @@ export function PullRequestThreadDialog({...@@ -138,6 +155,7 @@ export function PullRequestThreadDialog({
138 if (!parsedReference || !resolvedPullRequest || !cwd) {155 if (!parsedReference || !resolvedPullRequest || !cwd) {
139 return;156 return;
140 }157 }
158 setPrepareErrorMessage(null);
141 setPreparingMode(mode);159 setPreparingMode(mode);
142 const result = await preparePullRequestThreadAction.run({160 const result = await preparePullRequestThreadAction.run({
143 reference: parsedReference,161 reference: parsedReference,
...@@ -148,6 +166,13 @@ export function PullRequestThreadDialog({...@@ -148,6 +166,13 @@ export function PullRequestThreadDialog({
148 if (result._tag === "Failure") {166 if (result._tag === "Failure") {
149 if (isAtomCommandInterrupted(result)) {167 if (isAtomCommandInterrupted(result)) {
150 preparePullRequestThreadAction.resetError();168 preparePullRequestThreadAction.resetError();
169 } else {
170 const error = squashAtomCommandFailure(result);
171 setPrepareErrorMessage(
172 error instanceof Error
173 ? error.message
174 : `Failed to prepare ${terminology.singular} thread.`,
175 );
151 }176 }
152 return;177 return;
153 }178 }
...@@ -159,11 +184,13 @@ export function PullRequestThreadDialog({...@@ -159,11 +184,13 @@ export function PullRequestThreadDialog({
159 },184 },
160 [185 [
161 cwd,186 cwd,
187 environmentId,
162 onOpenChange,188 onOpenChange,
163 onPrepared,189 onPrepared,
164 parsedReference,190 parsedReference,
165 preparePullRequestThreadAction,191 preparePullRequestThreadAction,
166 resolvedPullRequest,192 resolvedPullRequest,
193 terminology.singular,
167 threadId,194 threadId,
168 ],195 ],
169 );196 );
...@@ -177,6 +204,7 @@ export function PullRequestThreadDialog({...@@ -177,6 +204,7 @@ export function PullRequestThreadDialog({
177 : null;204 : null;
178 const errorMessage =205 const errorMessage =
179 validationMessage ??206 validationMessage ??
207 prepareErrorMessage ??
180 (resolvedPullRequest === null && pullRequestResolution.error208 (resolvedPullRequest === null && pullRequestResolution.error
181 ? pullRequestResolution.error209 ? pullRequestResolution.error
182 : preparePullRequestThreadAction.error instanceof Error210 : preparePullRequestThreadAction.error instanceof Error
...@@ -273,10 +301,9 @@ export function PullRequestThreadDialog({...@@ -273,10 +301,9 @@ export function PullRequestThreadDialog({
273 type="button"301 type="button"
274 size="sm"302 size="sm"
275 variant="outline"303 variant="outline"
276 onClick={() => {304 onClick={() => handleConfirm("local")}
277 void handleConfirm("local");
278 }}
279 disabled={305 disabled={
306 !preparePullRequestThreadAction.isAllowed ||
280 !cwd ||307 !cwd ||
281 !resolvedPullRequest ||308 !resolvedPullRequest ||
282 isResolving ||309 isResolving ||
...@@ -288,10 +315,10 @@ export function PullRequestThreadDialog({...@@ -288,10 +315,10 @@ export function PullRequestThreadDialog({
288 <Button315 <Button
289 type="button"316 type="button"
290 size="sm"317 size="sm"
291 onClick={() => {318 onClick={() => handleConfirm("worktree")}
292 void handleConfirm("worktree");
293 }}
294 disabled={319 disabled={
320 !preparePullRequestThreadAction.isAllowed ||
321 !canOperateThread ||
295 !cwd ||322 !cwd ||
296 !resolvedPullRequest ||323 !resolvedPullRequest ||
297 isResolving ||324 isResolving ||
apps/web/src/components/Sidebar.tsx+183-61
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { type EnvironmentId } from "@t3tools/contracts";
1import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard";2import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard";
2import { CollapsibleSectionHeader } from "./ui/collapsible-section-header";3import { CollapsibleSectionHeader } from "./ui/collapsible-section-header";
3import { setThreadChangeRequestSnapshot } from "./ThreadStatusIndicators";4import { setThreadChangeRequestSnapshot } from "./ThreadStatusIndicators";
...@@ -52,6 +53,7 @@ import {...@@ -52,6 +53,7 @@ import {
52 scopedThreadKey,53 scopedThreadKey,
53} from "@t3tools/client-runtime/environment";54} from "@t3tools/client-runtime/environment";
54import {55import {
56 AuthOrchestrationOperateScope,
55 type EnvironmentMachineKind,57 type EnvironmentMachineKind,
56 type ScopedThreadRef,58 type ScopedThreadRef,
57 type ThreadId,59 type ThreadId,
...@@ -158,7 +160,8 @@ import { vcsEnvironment } from "../state/vcs";...@@ -158,7 +160,8 @@ import { vcsEnvironment } from "../state/vcs";
158import { threadEnvironment } from "../state/threads";160import { threadEnvironment } from "../state/threads";
159import { useEnvironmentQuery } from "../state/query";161import { useEnvironmentQuery } from "../state/query";
160import { useThreadSearch } from "../state/queries";162import { useThreadSearch } from "../state/queries";
161import { useAtomCommand } from "../state/use-atom-command";163import { useOrchestrationCommand } from "../state/use-orchestration-command";
164import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
162import {165import {
163 buildThreadRouteParams,166 buildThreadRouteParams,
164 resolveActiveThreadRouteRef,167 resolveActiveThreadRouteRef,
...@@ -170,7 +173,11 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";...@@ -170,7 +173,11 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
170import { cn } from "~/lib/utils";173import { cn } from "~/lib/utils";
171import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";174import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
172import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge";175import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge";
173import { buildDraftActionMenuItems, buildThreadActionMenuItems } from "./threadActionMenu.logic";176import {
177 buildDraftActionMenuItems,
178 buildThreadActionMenuItems,
179 threadActionRequiresOperate,
180} from "./threadActionMenu.logic";
174import {181import {
175 animateSidebarLayoutChanges,182 animateSidebarLayoutChanges,
176 applySidebarThreadDrop,183 applySidebarThreadDrop,
...@@ -296,6 +303,26 @@ const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded";...@@ -296,6 +303,26 @@ const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded";
296// Module scope keeps the inbox order across routes that unmount the sidebar.303// Module scope keeps the inbox order across routes that unmount the sidebar.
297const inboxReturns = createInboxReturnTracker();304const inboxReturns = createInboxReturnTracker();
298305
306function canOperateThreads(
307 threads: ReadonlyArray<Pick<SidebarThreadSummary, "environmentId">>,
308): boolean {
309 return threads.every((thread) =>
310 readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope),
311 );
312}
313
314function checkThreadOperations(
315 threads: ReadonlyArray<Pick<SidebarThreadSummary, "environmentId">>,
316): boolean {
317 if (canOperateThreads(threads)) return true;
318 toastManager.add({
319 type: "error",
320 title: "Thread action unavailable",
321 description: "This connection cannot change one or more selected threads.",
322 });
323 return false;
324}
325
299function compactSidebarTimeLabel(label: string): string {326function compactSidebarTimeLabel(label: string): string {
300 if (label === "just now") return "now";327 if (label === "just now") return "now";
301 return label.endsWith(" ago") ? label.slice(0, -4) : label;328 return label.endsWith(" ago") ? label.slice(0, -4) : label;
...@@ -619,11 +646,13 @@ function SortableThreadRow(props: {...@@ -619,11 +646,13 @@ function SortableThreadRow(props: {
619 id: string;646 id: string;
620 disabled: boolean;647 disabled: boolean;
621 contextDrag: boolean;648 contextDrag: boolean;
649 environmentId: EnvironmentId;
622 children: (bag: SortableThreadRowBag) => ReactNode;650 children: (bag: SortableThreadRowBag) => ReactNode;
623}) {651}) {
652 const canOperateThread = useEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope);
624 const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({653 const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
625 id: props.id,654 id: props.id,
626 disabled: { draggable: props.disabled },655 disabled: { draggable: props.disabled || !canOperateThread },
627 animateLayoutChanges: animateSidebarLayoutChanges,656 animateLayoutChanges: animateSidebarLayoutChanges,
628 });657 });
629 // dnd-kit memoizes each field but not the bag, so the memoized row would658 // dnd-kit memoizes each field but not the bag, so the memoized row would
...@@ -1173,6 +1202,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1173,6 +1202,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1173 [thread.environmentId, thread.id],1202 [thread.environmentId, thread.id],
1174 );1203 );
1175 const threadKey = scopedThreadKey(threadRef);1204 const threadKey = scopedThreadKey(threadRef);
1205 const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope);
1206 useEffect(() => {
1207 if (!canOperateThread && isRenaming) onCancelRename();
1208 }, [canOperateThread, isRenaming, onCancelRename]);
1176 const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive);1209 const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive);
1177 const isRegeneratingTitle = thread.titleRegeneration != null;1210 const isRegeneratingTitle = thread.titleRegeneration != null;
1178 const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);1211 const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
...@@ -1389,7 +1422,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1389,7 +1422,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1389 );1422 );
1390 const handleDoubleClick = useCallback(1423 const handleDoubleClick = useCallback(
1391 (event: ReactMouseEvent) => {1424 (event: ReactMouseEvent) => {
1392 if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {1425 if (
1426 !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) ||
1427 isRenaming ||
1428 event.metaKey ||
1429 event.ctrlKey ||
1430 event.shiftKey ||
1431 event.altKey
1432 ) {
1393 return;1433 return;
1394 }1434 }
1395 if ((event.target as HTMLElement).closest("button, a, input")) return;1435 if ((event.target as HTMLElement).closest("button, a, input")) return;
...@@ -1497,7 +1537,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1497,7 +1537,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1497 // Snooze is offered only where it can succeed: capability-gated and never1537 // Snooze is offered only where it can succeed: capability-gated and never
1498 // on blocked-on-you work or queued turns (the server rejects both).1538 // on blocked-on-you work or queued turns (the server rejects both).
1499 const showSnoozeButton =1539 const showSnoozeButton =
1500 props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() });1540 canOperateThread &&
1541 props.snoozeSupported &&
1542 canSnooze(thread, { now: new Date().toISOString() });
1543 const showHoverActions =
1544 (canOperateThread && props.settlementSupported) || showSnoozeButton || hasUnsentDraft;
1501 // If the thread becomes blocked while the popover is open, the button1545 // If the thread becomes blocked while the popover is open, the button
1502 // unmounts without firing onOpenChange(false). Deriving the flag keeps a1546 // unmounts without firing onOpenChange(false). Deriving the flag keeps a
1503 // stale true from permanently hiding the status label / pinning the1547 // stale true from permanently hiding the status label / pinning the
...@@ -1600,52 +1644,53 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1600,52 +1644,53 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1600 isActive: props.isActive,1644 isActive: props.isActive,
1601 });1645 });
16021646
1603 const title = isRenaming ? (1647 const title =
1604 <input1648 isRenaming && canOperateThread ? (
1605 autoFocus1649 <input
1606 value={renamingTitle}1650 autoFocus
1607 aria-label="Thread title"1651 value={renamingTitle}
1608 onChange={(event) => onRenameTitleChange(event.target.value)}1652 aria-label="Thread title"
1609 onFocus={(event) => event.currentTarget.select()}1653 onChange={(event) => onRenameTitleChange(event.target.value)}
1610 onKeyDown={handleRenameKeyDown}1654 onFocus={(event) => event.currentTarget.select()}
1611 onBlur={handleRenameBlur}1655 onKeyDown={handleRenameKeyDown}
1612 onClick={(event) => event.stopPropagation()}1656 onBlur={handleRenameBlur}
1613 onDoubleClick={(event) => event.stopPropagation()}1657 onClick={(event) => event.stopPropagation()}
1614 className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground"1658 onDoubleClick={(event) => event.stopPropagation()}
1615 />1659 className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground"
1616 ) : (1660 />
1617 <span1661 ) : (
1618 aria-hidden1662 <span
1619 className={cn(1663 aria-hidden
1620 "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none",1664 className={cn(
1621 shouldRecede ? "font-normal" : "font-medium",1665 "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none",
1622 variant === "card"1666 shouldRecede ? "font-normal" : "font-medium",
1623 ? cn(1667 variant === "card"
1624 "truncate",1668 ? cn(
1625 shouldRecede1669 "truncate",
1626 ? "text-secondary-label"1670 shouldRecede
1627 : isUnread || isWoke || status === "input"1671 ? "text-secondary-label"
1628 ? "text-foreground"1672 : isUnread || isWoke || status === "input"
1629 : status === "failed"1673 ? "text-foreground"
1630 ? "text-foreground/95"1674 : status === "failed"
1631 : "text-foreground/90",1675 ? "text-foreground/95"
1632 )1676 : "text-foreground/90",
1633 : cn(1677 )
1634 "truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground",1678 : cn(
1635 shouldRecede1679 "truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground",
1636 ? "text-secondary-label/70"1680 shouldRecede
1637 : props.isActive || isWoke || status === "input"1681 ? "text-secondary-label/70"
1638 ? "text-foreground"1682 : props.isActive || isWoke || status === "input"
1639 : isUnread1683 ? "text-foreground"
1640 ? "text-muted-foreground"1684 : isUnread
1641 : "text-secondary-label/70",1685 ? "text-muted-foreground"
1642 ),1686 : "text-secondary-label/70",
1643 isRegeneratingTitle && "opacity-55",1687 ),
1644 )}1688 isRegeneratingTitle && "opacity-55",
1645 >1689 )}
1646 {thread.title}1690 >
1647 </span>1691 {thread.title}
1648 );1692 </span>
1693 );
1649 const accessibleTitle = isRenaming ? null : <span className="sr-only">{thread.title}</span>;1694 const accessibleTitle = isRenaming ? null : <span className="sr-only">{thread.title}</span>;
16501695
1651 // Stacks show their layer count; multiple unrelated links show their total count.1696 // Stacks show their layer count; multiple unrelated links show their total count.
...@@ -1706,7 +1751,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1706,7 +1751,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1706 const showPin =1751 const showPin =
1707 props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null));1752 props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null));
1708 const pinIndicator = showPin ? (1753 const pinIndicator = showPin ? (
1709 props.pinningSupported && !sortable?.isDragging ? (1754 props.pinningSupported && canOperateThread && !sortable?.isDragging ? (
1710 <Tooltip>1755 <Tooltip>
1711 <TooltipTrigger1756 <TooltipTrigger
1712 render={1757 render={
...@@ -2350,7 +2395,7 @@ export default function Sidebar() {...@@ -2350,7 +2395,7 @@ export default function Sidebar() {
2350 archiveThread,2395 archiveThread,
2351 deleteThread,2396 deleteThread,
2352 } = useThreadActions();2397 } = useThreadActions();
2353 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {2398 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
2354 reportFailure: false,2399 reportFailure: false,
2355 });2400 });
2356 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({2401 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
...@@ -3180,6 +3225,7 @@ export default function Sidebar() {...@@ -3180,6 +3225,7 @@ export default function Sidebar() {
3180 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);3225 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
3181 const [renamingTitle, setRenamingTitle] = useState("");3226 const [renamingTitle, setRenamingTitle] = useState("");
3182 const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => {3227 const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => {
3228 if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return;
3183 setRenamingThreadKey(scopedThreadKey(threadRef));3229 setRenamingThreadKey(scopedThreadKey(threadRef));
3184 setRenamingTitle(title);3230 setRenamingTitle(title);
3185 }, []);3231 }, []);
...@@ -3187,6 +3233,7 @@ export default function Sidebar() {...@@ -3187,6 +3233,7 @@ export default function Sidebar() {
3187 const commitThreadRename = useCallback(3233 const commitThreadRename = useCallback(
3188 (threadRef: ScopedThreadRef, title: string, originalTitle: string) => {3234 (threadRef: ScopedThreadRef, title: string, originalTitle: string) => {
3189 void (async () => {3235 void (async () => {
3236 if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return;
3190 const trimmed = title.trim();3237 const trimmed = title.trim();
3191 setRenamingThreadKey(null);3238 setRenamingThreadKey(null);
3192 if (trimmed.length === 0) {3239 if (trimmed.length === 0) {
...@@ -3960,6 +4007,17 @@ export default function Sidebar() {...@@ -3960,6 +4007,17 @@ export default function Sidebar() {
3960 : plan.kind === "reorder-pinned" || plan.kind === "move-active"4007 : plan.kind === "reorder-pinned" || plan.kind === "move-active"
3961 ? plan.assignments4008 ? plan.assignments
3962 : [];4009 : [];
4010 // A drop rewrites the dragged row and every row it renumbers, so all of
4011 // them must be writable before any of them changes.
4012 if (
4013 !checkThreadOperations(
4014 [activeKey, ...assignments.map(({ id }) => id)].flatMap((key) => {
4015 const thread = threadByKey.get(key);
4016 return thread ? [thread] : [];
4017 }),
4018 )
4019 )
4020 return;
3963 const drop = {4021 const drop = {
3964 key: activeKey,4022 key: activeKey,
3965 sourceSection: activeSection,4023 sourceSection: activeSection,
...@@ -4174,6 +4232,9 @@ export default function Sidebar() {...@@ -4174,6 +4232,9 @@ export default function Sidebar() {
4174 const thread = threadByKeyRef.current.get(threadKey);4232 const thread = threadByKeyRef.current.get(threadKey);
4175 return thread ? [thread] : [];4233 return thread ? [thread] : [];
4176 });4234 });
4235 const settlingThreads = selectedThreads.filter(
4236 (thread) => thread.settledOverride !== "settled",
4237 );
4177 const canSnoozeSelection = selectedThreads.every(4238 const canSnoozeSelection = selectedThreads.every(
4178 (thread) =>4239 (thread) =>
4179 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true &&4240 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true &&
...@@ -4206,31 +4267,73 @@ export default function Sidebar() {...@@ -4206,31 +4267,73 @@ export default function Sidebar() {
4206 const clicked = await settlePromise(() =>4267 const clicked = await settlePromise(() =>
4207 api.contextMenu.show(4268 api.contextMenu.show(
4208 [4269 [
4209 ...(unpinMenuItem ? [unpinMenuItem] : []),4270 ...(unpinMenuItem
4210 { id: "settle", label: `Settle (${count})` },4271 ? [{ ...unpinMenuItem, disabled: !canOperateThreads(pinnedSelectedThreads) }]
4272 : []),
4273 {
4274 id: "settle",
4275 label: `Settle (${count})`,
4276 disabled: settlingThreads.length === 0 || !canOperateThreads(settlingThreads),
4277 },
4211 ...(canSnoozeSelection4278 ...(canSnoozeSelection
4212 ? [4279 ? [
4213 {4280 {
4214 id: "snooze",4281 id: "snooze",
4215 label: `Snooze (${count})`,4282 label: `Snooze (${count})`,
4283 disabled: !canOperateThreads(selectedThreads),
4216 children: [4284 children: [
4217 ...snoozePresets.map((preset) => ({4285 ...snoozePresets.map((preset) => ({
4218 id: `snooze:${preset.id}`,4286 id: `snooze:${preset.id}`,
4219 label: `${preset.label} (${preset.whenLabel})`,4287 label: `${preset.label} (${preset.whenLabel})`,
4288 disabled: !canOperateThreads(selectedThreads),
4220 })),4289 })),
4221 { id: "snooze:custom", label: "Custom…", separatorBefore: true },4290 {
4291 id: "snooze:custom",
4292 label: "Custom…",
4293 separatorBefore: true,
4294 disabled: !canOperateThreads(selectedThreads),
4295 },
4222 ],4296 ],
4223 },4297 },
4224 ]4298 ]
4225 : []),4299 : []),
4226 ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []),4300 ...(titleRegenerationMenuItem
4301 ? [
4302 {
4303 ...titleRegenerationMenuItem,
4304 disabled:
4305 titleRegenerationMenuItem.disabled ||
4306 !canOperateThreads(regeneratableTitleThreads),
4307 },
4308 ]
4309 : []),
4227 { id: "mark-unread", label: `Mark unread (${count})` },4310 { id: "mark-unread", label: `Mark unread (${count})` },
4228 { id: "delete", label: `Delete (${count})`, destructive: true },4311 {
4312 id: "delete",
4313 label: `Delete (${count})`,
4314 destructive: true,
4315 disabled: !canOperateThreads(selectedThreads),
4316 },
4229 ],4317 ],
4230 position,4318 position,
4231 ),4319 ),
4232 );4320 );
4233 if (clicked._tag === "Failure") return;4321 if (clicked._tag === "Failure" || clicked.value === null) return;
4322 const actionTargets =
4323 clicked.value === "unpin"
4324 ? pinnedSelectedThreads
4325 : clicked.value === "regenerate-title"
4326 ? regeneratableTitleThreads
4327 : clicked.value === "settle"
4328 ? settlingThreads.flatMap((thread) => {
4329 const current = threadByKeyRef.current.get(
4330 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
4331 );
4332 return current && current.settledOverride !== "settled" ? [current] : [];
4333 })
4334 : selectedThreads;
4335 if (clicked.value === "settle" && actionTargets.length === 0) return;
4336 if (clicked.value !== "mark-unread" && !checkThreadOperations(actionTargets)) return;
4234 if (clicked.value?.startsWith("snooze:")) {4337 if (clicked.value?.startsWith("snooze:")) {
4235 const preset =4338 const preset =
4236 clicked.value === "snooze:custom"4339 clicked.value === "snooze:custom"
...@@ -4303,7 +4406,11 @@ export default function Sidebar() {...@@ -4303,7 +4406,11 @@ export default function Sidebar() {
4303 return;4406 return;
4304 }4407 }
4305 if (clicked.value === "settle") {4408 if (clicked.value === "settle") {
4306 settleThreads(threadKeys);4409 settleThreads(
4410 actionTargets.map((thread) =>
4411 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
4412 ),
4413 );
4307 clearSelection();4414 clearSelection();
4308 return;4415 return;
4309 }4416 }
...@@ -4328,6 +4435,15 @@ export default function Sidebar() {...@@ -4328,6 +4435,15 @@ export default function Sidebar() {
4328 );4435 );
4329 if (confirmed._tag === "Failure" || !confirmed.value) return;4436 if (confirmed._tag === "Failure" || !confirmed.value) return;
4330 }4437 }
4438 if (
4439 !checkThreadOperations(
4440 threadKeys.flatMap((threadKey) => {
4441 const thread = threadByKeyRef.current.get(threadKey);
4442 return thread ? [thread] : [];
4443 }),
4444 )
4445 )
4446 return;
4331 const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({4447 const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({
4332 entries: threadKeys.map((threadKey) => ({ threadKey })),4448 entries: threadKeys.map((threadKey) => ({ threadKey })),
4333 delete: async ({ threadKey }, deletedThreadKeys) => {4449 delete: async ({ threadKey }, deletedThreadKeys) => {
...@@ -4469,6 +4585,10 @@ export default function Sidebar() {...@@ -4469,6 +4585,10 @@ export default function Sidebar() {
4469 const clicked = await settlePromise(() =>4585 const clicked = await settlePromise(() =>
4470 api.contextMenu.show(4586 api.contextMenu.show(
4471 buildThreadActionMenuItems({4587 buildThreadActionMenuItems({
4588 canOperate: readEnvironmentScope(
4589 threadRef.environmentId,
4590 AuthOrchestrationOperateScope,
4591 ),
4472 branch: thread.branch ?? null,4592 branch: thread.branch ?? null,
4473 projectFilter: threadProjectGroup4593 projectFilter: threadProjectGroup
4474 ? {4594 ? {
...@@ -4495,7 +4615,8 @@ export default function Sidebar() {...@@ -4495,7 +4615,8 @@ export default function Sidebar() {
4495 position,4615 position,
4496 ),4616 ),
4497 );4617 );
4498 if (clicked._tag === "Failure") return;4618 if (clicked._tag === "Failure" || clicked.value === null) return;
4619 if (threadActionRequiresOperate(clicked.value) && !checkThreadOperations([thread])) return;
4499 if (clicked.value?.startsWith("snooze:")) {4620 if (clicked.value?.startsWith("snooze:")) {
4500 const preset =4621 const preset =
4501 clicked.value === "snooze:custom"4622 clicked.value === "snooze:custom"
...@@ -5228,6 +5349,7 @@ export default function Sidebar() {...@@ -5228,6 +5349,7 @@ export default function Sidebar() {
5228 key={threadKey}5349 key={threadKey}
5229 id={threadKey}5350 id={threadKey}
5230 contextDrag={isContextDrag}5351 contextDrag={isContextDrag}
5352 environmentId={thread.environmentId}
5231 disabled={5353 disabled={
5232 renamingThreadKey === threadKey ||5354 renamingThreadKey === threadKey ||
5233 section === "working" ||5355 section === "working" ||
apps/web/src/components/chat/ChatHeader.tsx+30-7
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { type EnvironmentId, type ThreadId } from "@t3tools/contracts";1import {
2 AuthOrchestrationOperateScope,
3 type EnvironmentId,
4 type ThreadId,
5} from "@t3tools/contracts";
2import { scopeThreadRef } from "@t3tools/client-runtime/environment";6import { scopeThreadRef } from "@t3tools/client-runtime/environment";
3import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";7import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
4import {8import {
...@@ -23,6 +27,8 @@ import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";...@@ -23,6 +27,8 @@ import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";
23import { readLocalApi } from "~/localApi";27import { readLocalApi } from "~/localApi";
24import { threadEnvironment } from "../../state/threads";28import { threadEnvironment } from "../../state/threads";
25import { useAtomCommand } from "../../state/use-atom-command";29import { useAtomCommand } from "../../state/use-atom-command";
30import { useOrchestrationCommand } from "../../state/use-orchestration-command";
31import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
26import { ProjectFavicon } from "../ProjectFavicon";32import { ProjectFavicon } from "../ProjectFavicon";
27import {33import {
28 WorkspaceBreadcrumb,34 WorkspaceBreadcrumb,
...@@ -81,7 +87,11 @@ export const ChatHeader = memo(function ChatHeader({...@@ -81,7 +87,11 @@ export const ChatHeader = memo(function ChatHeader({
81 () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId),87 () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId),
82 [activeThreadEnvironmentId, activeThreadId],88 [activeThreadEnvironmentId, activeThreadId],
83 );89 );
84 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {90 const canOperateThread = useEnvironmentScope(
91 activeThreadEnvironmentId,
92 AuthOrchestrationOperateScope,
93 );
94 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
85 reportFailure: false,95 reportFailure: false,
86 });96 });
87 // Inline rename, keyed by thread: navigating away drops an in-progress97 // Inline rename, keyed by thread: navigating away drops an in-progress
...@@ -94,23 +104,36 @@ export const ChatHeader = memo(function ChatHeader({...@@ -94,23 +104,36 @@ export const ChatHeader = memo(function ChatHeader({
94 } | null>(null);104 } | null>(null);
95 if (105 if (
96 renaming !== null &&106 renaming !== null &&
97 (renaming.threadId !== activeThreadId || renaming.environmentId !== activeThreadEnvironmentId)107 (renaming.threadId !== activeThreadId ||
108 renaming.environmentId !== activeThreadEnvironmentId ||
109 !canOperateThread)
98 ) {110 ) {
99 setRenaming(null);111 setRenaming(null);
100 }112 }
101 const renamingTitle = renaming?.threadId === activeThreadId ? renaming.title : null;113 const renamingTitle =
114 canOperateThread &&
115 renaming?.threadId === activeThreadId &&
116 renaming.environmentId === activeThreadEnvironmentId
117 ? renaming.title
118 : null;
102 const renameCommittedRef = useRef(false);119 const renameCommittedRef = useRef(false);
103 const startRename = useCallback(() => {120 const startRename = useCallback(() => {
121 if (
122 !isServerThread ||
123 !readEnvironmentScope(activeThreadEnvironmentId, AuthOrchestrationOperateScope)
124 )
125 return;
104 renameCommittedRef.current = false;126 renameCommittedRef.current = false;
105 setRenaming({127 setRenaming({
106 threadId: activeThreadId,
107 environmentId: activeThreadEnvironmentId,128 environmentId: activeThreadEnvironmentId,
129 threadId: activeThreadId,
108 title: activeThreadTitle,130 title: activeThreadTitle,
109 });131 });
110 }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle]);132 }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, isServerThread]);
111 const commitRename = useCallback(133 const commitRename = useCallback(
112 (title: string) => {134 (title: string) => {
113 setRenaming(null);135 setRenaming(null);
136 if (!readEnvironmentScope(activeThreadEnvironmentId, AuthOrchestrationOperateScope)) return;
114 const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle });137 const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle });
115 if (resolution.action === "reject-empty") {138 if (resolution.action === "reject-empty") {
116 toastManager.add({ type: "warning", title: "Thread title cannot be empty" });139 toastManager.add({ type: "warning", title: "Thread title cannot be empty" });
...@@ -303,7 +326,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -303,7 +326,7 @@ export const ChatHeader = memo(function ChatHeader({
303 aria-label={`Thread actions for ${activeThreadTitle}`}326 aria-label={`Thread actions for ${activeThreadTitle}`}
304 aria-haspopup="menu"327 aria-haspopup="menu"
305 onClick={openMenuFromTitle}328 onClick={openMenuFromTitle}
306 onDoubleClick={handleTitleDoubleClick}329 onDoubleClick={canOperateThread ? handleTitleDoubleClick : undefined}
307 onBlur={cancelPendingTitleMenu}330 onBlur={cancelPendingTitleMenu}
308 className="group/thread-title inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"331 className="group/thread-title inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
309 />332 />
apps/web/src/components/pullRequest/PullRequestCommentForm.tsx+4-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
1/**2/**
2 * The comment half of the floating composer: a remark on the pull request itself, optionally3 * The comment half of the floating composer: a remark on the pull request itself, optionally
3 * the one that closes or reopens it. The popover around it belongs to PullRequestComposer.4 * the one that closes or reopens it. The popover around it belongs to PullRequestComposer.
...@@ -6,7 +7,6 @@ import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3to...@@ -6,7 +7,6 @@ import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3to
6import { SendIcon } from "lucide-react";7import { SendIcon } from "lucide-react";
7import { useState, type RefObject } from "react";8import { useState, type RefObject } from "react";
89
9import { useAtomCommand } from "~/state/use-atom-command";
10import { pullRequestEnvironment } from "~/state/pullRequests";10import { pullRequestEnvironment } from "~/state/pullRequests";
1111
12import { Button } from "../ui/button";12import { Button } from "../ui/button";
...@@ -38,7 +38,9 @@ export function PullRequestCommentForm({...@@ -38,7 +38,9 @@ export function PullRequestCommentForm({
38}) {38}) {
39 const [body, setBody] = useState("");39 const [body, setBody] = useState("");
40 const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null);40 const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null);
41 const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false });41 const postComment = useAtomCommand(pullRequestEnvironment.comment, {
42 reportFailure: false,
43 });
42 const followUpAction =44 const followUpAction =
43 detail.state === "open" &&45 detail.state === "open" &&
44 detail.capabilities.actions.includes("close") &&46 detail.capabilities.actions.includes("close") &&
apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx+6-1
...@@ -24,6 +24,11 @@ const { newThread, prepareThread, refresh, Wrapper, Trigger } = vi.hoisted(() =>...@@ -24,6 +24,11 @@ const { newThread, prepareThread, refresh, Wrapper, Trigger } = vi.hoisted(() =>
24 </>24 </>
25 ),25 ),
26}));26}));
27vi.mock("~/state/session", async (original) => ({
28 ...(await original<typeof import("~/state/session")>()),
29 useEnvironmentScope: () => true,
30 readEnvironmentScope: () => true,
31}));
27vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));32vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));
28vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} }));33vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} }));
29vi.mock("~/state/entities", () => ({ useProjects: () => [], useServerConfigs: () => new Map() }));34vi.mock("~/state/entities", () => ({ useProjects: () => [], useServerConfigs: () => new Map() }));
...@@ -39,7 +44,7 @@ vi.mock("~/hooks/useSettings", () => ({...@@ -39,7 +44,7 @@ vi.mock("~/hooks/useSettings", () => ({
39vi.mock("~/hooks/useLiveRefresh", () => ({ useLiveRefresh: () => {} }));44vi.mock("~/hooks/useLiveRefresh", () => ({ useLiveRefresh: () => {} }));
40vi.mock("~/hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => newThread }));45vi.mock("~/hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => newThread }));
41vi.mock("~/lib/sourceControlActions", () => ({46vi.mock("~/lib/sourceControlActions", () => ({
42 usePreparePullRequestThreadAction: () => ({ run: prepareThread }),47 usePreparePullRequestThreadAction: () => ({ run: prepareThread, isAllowed: true, error: null }),
43}));48}));
44vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));49vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
45vi.mock("~/state/pullRequests", async (importOriginal) => ({50vi.mock("~/state/pullRequests", async (importOriginal) => ({
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+59-12
...@@ -5,6 +5,8 @@ import { RefreshIcon } from "~/components/ui/refresh-icon";...@@ -5,6 +5,8 @@ import { RefreshIcon } from "~/components/ui/refresh-icon";
5import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";5import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
6import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";6import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
7import {7import {
8 AuthOrchestrationOperateScope,
9 AuthSourceControlWriteScope,
8 type EnvironmentId,10 type EnvironmentId,
9 type PullRequestAction,11 type PullRequestAction,
10 type PullRequestMergeMethod,12 type PullRequestMergeMethod,
...@@ -70,6 +72,7 @@ import type { ReviewCommentContext } from "~/reviewCommentContext";...@@ -70,6 +72,7 @@ import type { ReviewCommentContext } from "~/reviewCommentContext";
70import { useProjects, useServerConfigs } from "~/state/entities";72import { useProjects, useServerConfigs } from "~/state/entities";
71import { useEnvironments } from "~/state/environments";73import { useEnvironments } from "~/state/environments";
72import { useEnvironmentQuery } from "~/state/query";74import { useEnvironmentQuery } from "~/state/query";
75import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
73import { useLiveRefresh } from "~/hooks/useLiveRefresh";76import { useLiveRefresh } from "~/hooks/useLiveRefresh";
74import {77import {
75 pullRequestEnvironment,78 pullRequestEnvironment,
...@@ -578,6 +581,7 @@ export function PullRequestDetailPanel({...@@ -578,6 +581,7 @@ export function PullRequestDetailPanel({
578 // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself581 // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself
579 // alone. One at a time whatever the key: they all check the same pull request out.582 // alone. One at a time whatever the key: they all check the same pull request out.
580 const [handoff, setHandoff] = useState<string | null>(null);583 const [handoff, setHandoff] = useState<string | null>(null);
584 const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope);
581 const detailQuery = useEnvironmentQuery(585 const detailQuery = useEnvironmentQuery(
582 pullRequestEnvironment.detail({ environmentId, input: reference }),586 pullRequestEnvironment.detail({ environmentId, input: reference }),
583 );587 );
...@@ -690,6 +694,25 @@ export function PullRequestDetailPanel({...@@ -690,6 +694,25 @@ export function PullRequestDetailPanel({
690 ? null694 ? null
691 : {695 : {
692 ...coreDetail,696 ...coreDetail,
697 capabilities: canWriteSourceControl
698 ? coreDetail.capabilities
699 : {
700 ...coreDetail.capabilities,
701 reactions: false,
702 edit: { changeRequest: false, comment: false },
703 },
704 viewerPermissions: canWriteSourceControl
705 ? coreDetail.viewerPermissions
706 : {
707 ...coreDetail.viewerPermissions,
708 actions: [],
709 comment: false,
710 resolve: false,
711 verdicts: [],
712 requestReviewers: false,
713 updateMethods: [],
714 labels: false,
715 },
693 author: activity?.author ?? coreDetail.author,716 author: activity?.author ?? coreDetail.author,
694 reviewers: activity?.reviewers ?? coreDetail.reviewers,717 reviewers: activity?.reviewers ?? coreDetail.reviewers,
695 comments: activity?.comments ?? [],718 comments: activity?.comments ?? [],
...@@ -699,7 +722,7 @@ export function PullRequestDetailPanel({...@@ -699,7 +722,7 @@ export function PullRequestDetailPanel({
699 commits: activity?.commits ?? [],722 commits: activity?.commits ?? [],
700 reactions: activity?.reactions ?? [],723 reactions: activity?.reactions ?? [],
701 },724 },
702 [activity, coreDetail],725 [activity, canWriteSourceControl, coreDetail],
703 );726 );
704 const handoffSummary = detail ?? sharedSummary;727 const handoffSummary = detail ?? sharedSummary;
705 const keybindings = useAtomValue(primaryServerKeybindingsAtom);728 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
...@@ -857,8 +880,12 @@ export function PullRequestDetailPanel({...@@ -857,8 +880,12 @@ export function PullRequestDetailPanel({
857 appliedForcedToken.current = forcedRefreshToken;880 appliedForcedToken.current = forcedRefreshToken;
858 void refreshFromHost();881 void refreshFromHost();
859 }, [forcedRefreshToken, refreshFromHost]);882 }, [forcedRefreshToken, refreshFromHost]);
860 const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });883 const runAction = useAtomCommand(pullRequestEnvironment.runAction, {
861 const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false });884 reportFailure: false,
885 });
886 const postComment = useAtomCommand(pullRequestEnvironment.comment, {
887 reportFailure: false,
888 });
862 // Which action is in flight, not merely that one is: every control here is disabled while any889 // Which action is in flight, not merely that one is: every control here is disabled while any
863 // of them runs, but only the button that was pressed may say what it is doing.890 // of them runs, but only the button that was pressed may say what it is doing.
864 const [pendingAction, setPendingAction] = useState<PullRequestAction | null>(null);891 const [pendingAction, setPendingAction] = useState<PullRequestAction | null>(null);
...@@ -912,6 +939,7 @@ export function PullRequestDetailPanel({...@@ -912,6 +939,7 @@ export function PullRequestDetailPanel({
912 const actingEnvironmentId = acting?.environmentId ?? environmentId;939 const actingEnvironmentId = acting?.environmentId ?? environmentId;
913 const checkoutRoot =940 const checkoutRoot =
914 acting?.workspaceRoot ?? detail?.workspaceRoot ?? project?.workspaceRoot ?? null;941 acting?.workspaceRoot ?? detail?.workspaceRoot ?? project?.workspaceRoot ?? null;
942 const canOperateThread = useEnvironmentScope(actingEnvironmentId, AuthOrchestrationOperateScope);
915 const prepareThread = usePreparePullRequestThreadAction({943 const prepareThread = usePreparePullRequestThreadAction({
916 environmentId: actingEnvironmentId,944 environmentId: actingEnvironmentId,
917 cwd: checkoutRoot,945 cwd: checkoutRoot,
...@@ -973,13 +1001,13 @@ export function PullRequestDetailPanel({...@@ -973,13 +1001,13 @@ export function PullRequestDetailPanel({
973 method?: PullRequestMergeMethod,1001 method?: PullRequestMergeMethod,
974 updateMethod?: PullRequestUpdateMethod,1002 updateMethod?: PullRequestUpdateMethod,
975 ) => {1003 ) => {
976 if (pendingAction !== null) return false;1004 if (!canWriteSourceControl || pendingAction !== null) return false;
977 setPendingAction(action);1005 setPendingAction(action);
978 return finishAction(action, method, updateMethod);1006 return finishAction(action, method, updateMethod);
979 };1007 };
9801008
981 const performCommentAction = async (body: string, action: "close" | "reopen") => {1009 const performCommentAction = async (body: string, action: "close" | "reopen") => {
982 if (pendingAction !== null) return { commentPosted: false };1010 if (!canWriteSourceControl || pendingAction !== null) return { commentPosted: false };
983 setPendingAction(action);1011 setPendingAction(action);
984 const commentResult = await postComment({1012 const commentResult = await postComment({
985 environmentId,1013 environmentId,
...@@ -999,7 +1027,7 @@ export function PullRequestDetailPanel({...@@ -999,7 +1027,7 @@ export function PullRequestDetailPanel({
9991027
1000 const saveTitle = async (next: string) => {1028 const saveTitle = async (next: string) => {
1001 const title = next.trim();1029 const title = next.trim();
1002 if (detail === null || titleSaving) return;1030 if (!canWriteSourceControl || detail === null || titleSaving) return;
1003 if (title.length === 0 || title === detail.title) {1031 if (title.length === 0 || title === detail.title) {
1004 setTitleScope(null);1032 setTitleScope(null);
1005 return;1033 return;
...@@ -1030,6 +1058,8 @@ export function PullRequestDetailPanel({...@@ -1030,6 +1058,8 @@ export function PullRequestDetailPanel({
1030 };1058 };
10311059
1032 const attachTarget = composerDraftTarget ?? null;1060 const attachTarget = composerDraftTarget ?? null;
1061 const canPrepareWorktree = prepareThread.isAllowed && canOperateThread;
1062 const canFixFindings = attachTarget !== null || canPrepareWorktree;
1033 const handoffLabels = pullRequestHandoffLabels(attachTarget !== null);1063 const handoffLabels = pullRequestHandoffLabels(attachTarget !== null);
10341064
1035 const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => {1065 const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => {
...@@ -1158,6 +1188,18 @@ export function PullRequestDetailPanel({...@@ -1158,6 +1188,18 @@ export function PullRequestDetailPanel({
1158 return;1188 return;
1159 }1189 }
1160 if (checkoutRoot === null) return;1190 if (checkoutRoot === null) return;
1191 if (
1192 !prepareThread.isAllowed ||
1193 !readEnvironmentScope(actingEnvironmentId, AuthSourceControlWriteScope)
1194 ) {
1195 return;
1196 }
1197 if (
1198 mode === "worktree" &&
1199 !readEnvironmentScope(actingEnvironmentId, AuthOrchestrationOperateScope)
1200 ) {
1201 return;
1202 }
1161 setHandoff(kind);1203 setHandoff(kind);
1162 // The menu closes on the press and takes its "Preparing..." label with it, so this is the1204 // The menu closes on the press and takes its "Preparing..." label with it, so this is the
1163 // only thing answering for the checkout. It carries no timeout of its own: a loading toast1205 // only thing answering for the checkout. It carries no timeout of its own: a loading toast
...@@ -1528,7 +1570,7 @@ export function PullRequestDetailPanel({...@@ -1528,7 +1570,7 @@ export function PullRequestDetailPanel({
1528 <TooltipPopup>Check out this pull request</TooltipPopup>1570 <TooltipPopup>Check out this pull request</TooltipPopup>
1529 </Tooltip>1571 </Tooltip>
1530 <MenuPopup align="end" side="bottom">1572 <MenuPopup align="end" side="bottom">
1531 <MenuItem onClick={() => startCheckout("worktree")}>1573 <MenuItem disabled={!canPrepareWorktree} onClick={() => startCheckout("worktree")}>
1532 <GitBranchIcon className="mt-1 size-3.5 shrink-0 self-start" />1574 <GitBranchIcon className="mt-1 size-3.5 shrink-0 self-start" />
1533 <span className="flex min-w-0 flex-col">1575 <span className="flex min-w-0 flex-col">
1534 <span>In a separate worktree</span>1576 <span>In a separate worktree</span>
...@@ -1537,7 +1579,7 @@ export function PullRequestDetailPanel({...@@ -1537,7 +1579,7 @@ export function PullRequestDetailPanel({
1537 </span>1579 </span>
1538 </span>1580 </span>
1539 </MenuItem>1581 </MenuItem>
1540 <MenuItem onClick={() => startCheckout("local")}>1582 <MenuItem disabled={!prepareThread.isAllowed} onClick={() => startCheckout("local")}>
1541 <FolderGit2Icon className="mt-1 size-3.5 shrink-0 self-start" />1583 <FolderGit2Icon className="mt-1 size-3.5 shrink-0 self-start" />
1542 <span className="flex min-w-0 flex-col">1584 <span className="flex min-w-0 flex-col">
1543 <span>In this repository</span>1585 <span>In this repository</span>
...@@ -2016,7 +2058,10 @@ export function PullRequestDetailPanel({...@@ -2016,7 +2058,10 @@ export function PullRequestDetailPanel({
2016 </span>2058 </span>
2017 </span>2059 </span>
2018 </MenuItem>2060 </MenuItem>
2019 <MenuItem disabled={handoff !== null} onClick={startFixFindings}>2061 <MenuItem
2062 disabled={handoff !== null || !canFixFindings}
2063 onClick={startFixFindings}
2064 >
2020 <HammerIcon className="size-3.5" />2065 <HammerIcon className="size-3.5" />
2021 {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings}2066 {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings}
2022 </MenuItem>2067 </MenuItem>
...@@ -2383,7 +2428,9 @@ export function PullRequestDetailPanel({...@@ -2383,7 +2428,9 @@ export function PullRequestDetailPanel({
2383 <Button2428 <Button
2384 size="xs"2429 size="xs"
2385 variant="outline"2430 variant="outline"
2386 disabled={titleSaving || titleDraft.trim().length === 0}2431 disabled={
2432 !canWriteSourceControl || titleSaving || titleDraft.trim().length === 0
2433 }
2387 onClick={() => void saveTitle(titleDraft)}2434 onClick={() => void saveTitle(titleDraft)}
2388 >2435 >
2389 {titleSaving ? "Saving..." : "Save"}2436 {titleSaving ? "Saving..." : "Save"}
...@@ -2706,7 +2753,7 @@ export function PullRequestDetailPanel({...@@ -2706,7 +2753,7 @@ export function PullRequestDetailPanel({
2706 pendingFinding={handoff}2753 pendingFinding={handoff}
2707 fixFindingLabel={handoffLabels.fixFinding}2754 fixFindingLabel={handoffLabels.fixFinding}
2708 fixCheckLabel={handoffLabels.fixCheck}2755 fixCheckLabel={handoffLabels.fixCheck}
2709 onFixFinding={startFixFinding}2756 {...(canFixFindings ? { onFixFinding: startFixFinding } : {})}
2710 onRefresh={refreshDetail}2757 onRefresh={refreshDetail}
2711 onRefreshChecks={refreshFromHost}2758 onRefreshChecks={refreshFromHost}
2712 />2759 />
...@@ -2746,7 +2793,7 @@ export function PullRequestDetailPanel({...@@ -2746,7 +2793,7 @@ export function PullRequestDetailPanel({
2746 onSelectedCommitChange={selectCodeCommit}2793 onSelectedCommitChange={selectCodeCommit}
2747 pendingFinding={handoff}2794 pendingFinding={handoff}
2748 fixFindingLabel={handoffLabels.fixFinding}2795 fixFindingLabel={handoffLabels.fixFinding}
2749 onFixFinding={startFixFinding}2796 {...(canFixFindings ? { onFixFinding: startFixFinding } : {})}
2750 onRefresh={refreshDetail}2797 onRefresh={refreshDetail}
2751 refreshToken={codeRefreshToken}2798 refreshToken={codeRefreshToken}
2752 />2799 />
apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx+4-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
1/**2/**
2 * Putting a label on, and taking one off, from the row that says which it already wears.3 * Putting a label on, and taking one off, from the row that says which it already wears.
3 *4 *
...@@ -11,7 +12,6 @@ import { useMemo, useState } from "react";...@@ -11,7 +12,6 @@ import { useMemo, useState } from "react";
1112
12import { pullRequestEnvironment } from "~/state/pullRequests";13import { pullRequestEnvironment } from "~/state/pullRequests";
13import { useEnvironmentQuery } from "~/state/query";14import { useEnvironmentQuery } from "~/state/query";
14import { useAtomCommand } from "~/state/use-atom-command";
15import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";15import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
1616
17import { toastManager } from "../ui/toast";17import { toastManager } from "../ui/toast";
...@@ -48,7 +48,9 @@ export function PullRequestLabelPicker({...@@ -48,7 +48,9 @@ export function PullRequestLabelPicker({
48 const candidatesQuery = useEnvironmentQuery(48 const candidatesQuery = useEnvironmentQuery(
49 open ? pullRequestEnvironment.labelCandidates({ environmentId, input: reference }) : null,49 open ? pullRequestEnvironment.labelCandidates({ environmentId, input: reference }) : null,
50 );50 );
51 const setLabels = useAtomCommand(pullRequestEnvironment.setLabels, { reportFailure: false });51 const setLabels = useAtomCommand(pullRequestEnvironment.setLabels, {
52 reportFailure: false,
53 });
5254
53 const candidates = useMemo(55 const candidates = useMemo(
54 () => (candidatesQuery.data?.candidates ?? []).filter((entry) => matches(entry, query)),56 () => (candidatesQuery.data?.candidates ?? []).filter((entry) => matches(entry, query)),
apps/web/src/components/pullRequest/PullRequestMarkdownEditor.test.tsx created+148
...@@ -0,0 +1,148 @@
1import { AuthSourceControlWriteScope, EnvironmentId } from "@t3tools/contracts";
2import { act } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
5
6const permissions = vi.hoisted(() => ({
7 allowed: new Set<string>(),
8 listeners: new Set<() => void>(),
9}));
10
11vi.mock("~/state/session", async () => {
12 const { useSyncExternalStore } = await import("react");
13 const canWrite = (environmentId: string, scope: string) =>
14 scope === AuthSourceControlWriteScope && permissions.allowed.has(environmentId);
15 return {
16 readEnvironmentScope: canWrite,
17 useEnvironmentScope: (environmentId: string, scope: string) =>
18 useSyncExternalStore(
19 (listener) => {
20 permissions.listeners.add(listener);
21 return () => permissions.listeners.delete(listener);
22 },
23 () => canWrite(environmentId, scope),
24 ),
25 };
26});
27vi.mock("~/lib/utils", () => ({ cn: () => "" }));
28vi.mock("../ui/button", () => ({ Button: "button" }));
29vi.mock("../ui/textarea", () => ({ Textarea: "textarea" }));
30vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" }));
31vi.mock("./PullRequestMarkdown", () => ({ PullRequestMarkdown: "article" }));
32
33import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";
34
35const primary = EnvironmentId.make("primary");
36const secondary = EnvironmentId.make("secondary");
37let renderer: ReactTestRenderer | undefined;
38const onSave = vi.fn<(next: string) => void>();
39const onCancel = vi.fn<() => void>();
40
41beforeEach(() => {
42 permissions.allowed.clear();
43 permissions.listeners.clear();
44 renderer = undefined;
45 onSave.mockReset();
46 onCancel.mockReset();
47 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
48});
49
50afterEach(async () => {
51 await act(() => renderer?.unmount());
52 vi.unstubAllGlobals();
53});
54
55async function openEditor(options?: { value?: string; allowEmpty?: boolean }) {
56 await act(() => {
57 renderer = create(
58 <PullRequestMarkdownEditor
59 value={options?.value ?? "Original description"}
60 cwd="/repo"
61 environmentId={secondary}
62 label="Pull request description"
63 saving={false}
64 allowEmpty={options?.allowEmpty ?? false}
65 onSave={onSave}
66 onCancel={onCancel}
67 />,
68 );
69 });
70}
71
72function saveHandler(): () => void {
73 return renderer!.root.findByProps({ children: "Save" }).props.onClick;
74}
75
76async function grant(allowed: boolean) {
77 await act(() => {
78 if (allowed) permissions.allowed.add(secondary);
79 else permissions.allowed.delete(secondary);
80 permissions.listeners.forEach((listener) => listener());
81 });
82}
83
84it("does not save a secondary environment's description using the primary grant", async () => {
85 permissions.allowed.add(primary);
86 await openEditor();
87
88 await act(() => saveHandler()());
89
90 expect(onSave).not.toHaveBeenCalled();
91});
92
93it("rejects a retained Save callback when the grant changes before React updates", async () => {
94 permissions.allowed.add(secondary);
95 await openEditor();
96 const save = saveHandler();
97 permissions.allowed.delete(secondary);
98
99 await act(() => save());
100
101 expect(onSave).not.toHaveBeenCalled();
102});
103
104it("keeps an edited draft through revocation and saves it after access returns without remounting", async () => {
105 permissions.allowed.add(secondary);
106 await openEditor();
107 await act(() => {
108 renderer!.root.findByType("textarea").props.onChange({
109 target: { value: "Keep these unsaved edits" },
110 });
111 });
112
113 await grant(false);
114 await act(() => saveHandler()());
115 expect(onSave).not.toHaveBeenCalled();
116
117 await grant(true);
118 await act(() => saveHandler()());
119 expect(onSave).toHaveBeenCalledExactlyOnceWith("Keep these unsaved edits");
120});
121
122it("saves with source-control permission alone", async () => {
123 permissions.allowed.add(secondary);
124 await openEditor();
125
126 await act(() => saveHandler()());
127
128 expect(onSave).toHaveBeenCalledExactlyOnceWith("Original description");
129});
130
131it("allows clearing a description only after a pending grant becomes available", async () => {
132 await openEditor({ value: "", allowEmpty: true });
133 await act(() => saveHandler()());
134 expect(onSave).not.toHaveBeenCalled();
135
136 await grant(true);
137 await act(() => saveHandler()());
138 expect(onSave).toHaveBeenCalledExactlyOnceWith("");
139});
140
141it("keeps cancelling a local edit available without a write grant", async () => {
142 await openEditor();
143
144 await act(() => renderer!.root.findByProps({ children: "Cancel" }).props.onClick());
145
146 expect(onCancel).toHaveBeenCalledOnce();
147 expect(onSave).not.toHaveBeenCalled();
148});
apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx+28-4
...@@ -1,7 +1,12 @@...@@ -1,7 +1,12 @@
1import { useState } from "react";1import { useState } from "react";
2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";2import {
3 AuthSourceControlWriteScope,
4 type EnvironmentId,
5 type ScopedThreadRef,
6} from "@t3tools/contracts";
37
4import { cn } from "~/lib/utils";8import { cn } from "~/lib/utils";
9import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
510
6import { Button } from "../ui/button";11import { Button } from "../ui/button";
7import { Textarea } from "../ui/textarea";12import { Textarea } from "../ui/textarea";
...@@ -43,6 +48,7 @@ export function PullRequestMarkdownEditor({...@@ -43,6 +48,7 @@ export function PullRequestMarkdownEditor({
43 readonly onSave: (next: string) => void;48 readonly onSave: (next: string) => void;
44 readonly onCancel: () => void;49 readonly onCancel: () => void;
45}) {50}) {
51 const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope);
46 const [draft, setDraft] = useState(value);52 const [draft, setDraft] = useState(value);
47 const [preview, setPreview] = useState(false);53 const [preview, setPreview] = useState(false);
48 // The words this draft started from. React keeps a component instance wherever the same54 // The words this draft started from. React keeps a component instance wherever the same
...@@ -55,7 +61,7 @@ export function PullRequestMarkdownEditor({...@@ -55,7 +61,7 @@ export function PullRequestMarkdownEditor({
55 setDraft(value);61 setDraft(value);
56 }62 }
57 const empty = draft.trim().length === 0;63 const empty = draft.trim().length === 0;
58 const saveDisabled = saving || (empty && !allowEmpty);64 const saveDisabled = !canWriteSourceControl || saving || (empty && !allowEmpty);
5965
60 return (66 return (
61 <div67 <div
...@@ -70,7 +76,12 @@ export function PullRequestMarkdownEditor({...@@ -70,7 +76,12 @@ export function PullRequestMarkdownEditor({
70 ) {76 ) {
71 event.preventDefault();77 event.preventDefault();
72 event.stopPropagation();78 event.stopPropagation();
73 if (!saveDisabled && !event.repeat) onSave(draft);79 if (
80 !saveDisabled &&
81 !event.repeat &&
82 readEnvironmentScope(environmentId, AuthSourceControlWriteScope)
83 )
84 onSave(draft);
74 return;85 return;
75 }86 }
76 if (event.key !== "Escape" || saving) return;87 if (event.key !== "Escape" || saving) return;
...@@ -119,7 +130,20 @@ export function PullRequestMarkdownEditor({...@@ -119,7 +130,20 @@ export function PullRequestMarkdownEditor({
119 <Button size="xs" variant="ghost" disabled={saving} onClick={onCancel}>130 <Button size="xs" variant="ghost" disabled={saving} onClick={onCancel}>
120 Cancel131 Cancel
121 </Button>132 </Button>
122 <Button size="xs" variant="outline" disabled={saveDisabled} onClick={() => onSave(draft)}>133 <Button
134 size="xs"
135 variant="outline"
136 disabled={!canWriteSourceControl || saving || (empty && !allowEmpty)}
137 onClick={() => {
138 if (
139 !readEnvironmentScope(environmentId, AuthSourceControlWriteScope) ||
140 saving ||
141 (empty && !allowEmpty)
142 )
143 return;
144 onSave(draft);
145 }}
146 >
123 {saving ? "Saving..." : "Save"}147 {saving ? "Saving..." : "Save"}
124 </Button>148 </Button>
125 </div>149 </div>
apps/web/src/components/pullRequest/PullRequestReactions.tsx+4-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
1import type {2import type {
2 EnvironmentId,3 EnvironmentId,
3 PullRequestReaction,4 PullRequestReaction,
...@@ -9,7 +10,6 @@ import { useState } from "react";...@@ -9,7 +10,6 @@ import { useState } from "react";
910
10import { cn } from "~/lib/utils";11import { cn } from "~/lib/utils";
11import { pullRequestEnvironment } from "~/state/pullRequests";12import { pullRequestEnvironment } from "~/state/pullRequests";
12import { useAtomCommand } from "~/state/use-atom-command";
1313
14import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";14import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
15import { toastManager } from "../ui/toast";15import { toastManager } from "../ui/toast";
...@@ -58,7 +58,9 @@ export function PullRequestReactionBar({...@@ -58,7 +58,9 @@ export function PullRequestReactionBar({
58 readonly signature: string;58 readonly signature: string;
59 readonly values: ReadonlyMap<PullRequestReactionContent, boolean>;59 readonly values: ReadonlyMap<PullRequestReactionContent, boolean>;
60 }>({ signature: "", values: EMPTY_PENDING });60 }>({ signature: "", values: EMPTY_PENDING });
61 const setReaction = useAtomCommand(pullRequestEnvironment.setReaction, { reportFailure: false });61 const setReaction = useAtomCommand(pullRequestEnvironment.setReaction, {
62 reportFailure: false,
63 });
6264
63 const signature = reactionsSignature(reactions);65 const signature = reactionsSignature(reactions);
64 const values = pending.signature === signature ? pending.values : EMPTY_PENDING;66 const values = pending.signature === signature ? pending.values : EMPTY_PENDING;
apps/web/src/components/pullRequest/PullRequestReviewForm.tsx+1-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
1/**2/**
2 * The review half of the floating composer: the summary and the verdict that sends it, together3 * The review half of the floating composer: the summary and the verdict that sends it, together
3 * with whatever line comments the review is holding. The count of those lives on the composer's4 * with whatever line comments the review is holding. The count of those lives on the composer's
...@@ -9,7 +10,6 @@ import { CheckIcon, MessageSquareIcon, XCircleIcon } from "lucide-react";...@@ -9,7 +10,6 @@ import { CheckIcon, MessageSquareIcon, XCircleIcon } from "lucide-react";
9import { useState, type ReactNode, type RefObject } from "react";10import { useState, type ReactNode, type RefObject } from "react";
1011
11import { pullRequestEnvironment } from "~/state/pullRequests";12import { pullRequestEnvironment } from "~/state/pullRequests";
12import { useAtomCommand } from "~/state/use-atom-command";
1313
14import { Button } from "../ui/button";14import { Button } from "../ui/button";
15import { Select, SelectItem, SelectPopup, SelectTrigger } from "../ui/select";15import { Select, SelectItem, SelectPopup, SelectTrigger } from "../ui/select";
apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx+1-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
1/**2/**
2 * Asking someone to review, from the row that says who is already reviewing.3 * Asking someone to review, from the row that says who is already reviewing.
3 *4 *
...@@ -15,7 +16,6 @@ import { useMemo, useState } from "react";...@@ -15,7 +16,6 @@ import { useMemo, useState } from "react";
1516
16import { pullRequestEnvironment } from "~/state/pullRequests";17import { pullRequestEnvironment } from "~/state/pullRequests";
17import { useEnvironmentQuery } from "~/state/query";18import { useEnvironmentQuery } from "~/state/query";
18import { useAtomCommand } from "~/state/use-atom-command";
19import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";19import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
2020
21import { toastManager } from "../ui/toast";21import { toastManager } from "../ui/toast";
apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx+6-1
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import { useAtomValue } from "@effect/atom-react";
1import type { PullRequestAction } from "@t3tools/contracts";2import type { PullRequestAction } from "@t3tools/contracts";
3import { pullRequestEnvironment } from "~/state/pullRequests";
2import { useUiStateStore } from "~/uiStateStore";4import { useUiStateStore } from "~/uiStateStore";
3import { Button } from "../ui/button";5import { Button } from "../ui/button";
4import { Spinner } from "../ui/spinner";6import { Spinner } from "../ui/spinner";
...@@ -37,6 +39,9 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr...@@ -37,6 +39,9 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr
37 sweeping?: boolean;39 sweeping?: boolean;
38 onCloseSweepStart?: (entry: Entry, event: PointerEvent) => void;40 onCloseSweepStart?: (entry: Entry, event: PointerEvent) => void;
39}) {41}) {
42 const canWrite = useAtomValue(
43 pullRequestEnvironment.runAction.permissionAtom(entry.environmentId),
44 );
40 const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver(45 const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver(
41 entry.environmentId,46 entry.environmentId,
42 entry.projectId,47 entry.projectId,
...@@ -90,7 +95,7 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr...@@ -90,7 +95,7 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr
90 <Button95 <Button
91 variant={action === "close" ? "destructive-outline" : "outline"}96 variant={action === "close" ? "destructive-outline" : "outline"}
92 size="xs"97 size="xs"
93 disabled={busy || (action === "merge" && entry.stack !== undefined)}98 disabled={!canWrite || busy || (action === "merge" && entry.stack !== undefined)}
94 aria-label={`${label} #${entry.number}`}99 aria-label={`${label} #${entry.number}`}
95 onClick={() => void perform(action)}100 onClick={() => void perform(action)}
96 onPointerDown={(event) => {101 onPointerDown={(event) => {
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+1-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
1import type {2import type {
2 EnvironmentId,3 EnvironmentId,
3 PullRequestComment,4 PullRequestComment,
...@@ -16,7 +17,6 @@ import {...@@ -16,7 +17,6 @@ import {
16} from "lucide-react";17} from "lucide-react";
17import { useRef, useState, type ReactNode } from "react";18import { useRef, useState, type ReactNode } from "react";
1819
19import { useAtomCommand } from "~/state/use-atom-command";
20import { pullRequestEnvironment } from "~/state/pullRequests";20import { pullRequestEnvironment } from "~/state/pullRequests";
21import { cn } from "~/lib/utils";21import { cn } from "~/lib/utils";
22import { useOpenLink } from "~/browser/useOpenLink";22import { useOpenLink } from "~/browser/useOpenLink";
apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx+1-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
1import type {2import type {
2 EnvironmentId,3 EnvironmentId,
3 PullRequestActor,4 PullRequestActor,
...@@ -18,7 +19,6 @@ import { useState, type ReactNode } from "react";...@@ -18,7 +19,6 @@ import { useState, type ReactNode } from "react";
18import { cn } from "~/lib/utils";19import { cn } from "~/lib/utils";
19import { readLocalApi } from "~/localApi";20import { readLocalApi } from "~/localApi";
20import { pullRequestEnvironment } from "~/state/pullRequests";21import { pullRequestEnvironment } from "~/state/pullRequests";
21import { useAtomCommand } from "~/state/use-atom-command";
22import { formatRelativeTimeLabel } from "~/timestampFormat";22import { formatRelativeTimeLabel } from "~/timestampFormat";
2323
24import { Button } from "../ui/button";24import { Button } from "../ui/button";
apps/web/src/components/pullRequest/usePullRequestActions.ts+4-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
1/**2/**
2 * The actions a pull request offers, extracted from the detail panel so smaller surfaces — the3 * The actions a pull request offers, extracted from the detail panel so smaller surfaces — the
3 * thread details panel's pull request row — perform them through the very same code. Two callers4 * thread details panel's pull request row — perform them through the very same code. Two callers
...@@ -31,7 +32,6 @@ import { useNewThreadHandler } from "~/hooks/useHandleNewThread";...@@ -31,7 +32,6 @@ import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
31import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";32import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
32import type { ReviewCommentContext } from "~/reviewCommentContext";33import type { ReviewCommentContext } from "~/reviewCommentContext";
33import { pullRequestEnvironment } from "~/state/pullRequests";34import { pullRequestEnvironment } from "~/state/pullRequests";
34import { useAtomCommand } from "~/state/use-atom-command";
3535
36import { toastManager } from "../ui/toast";36import { toastManager } from "../ui/toast";
37import { handoffPrompt, handoffReviewComments, readableFailure } from "./pullRequestDetail.logic";37import { handoffPrompt, handoffReviewComments, readableFailure } from "./pullRequestDetail.logic";
...@@ -140,7 +140,9 @@ export function usePullRequestActionRunner({...@@ -140,7 +140,9 @@ export function usePullRequestActionRunner({
140 /** Small surfaces resolve repository settings on the click, not for every visible row. */140 /** Small surfaces resolve repository settings on the click, not for every visible row. */
141 resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod;141 resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod;
142}) {142}) {
143 const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });143 const runAction = useAtomCommand(pullRequestEnvironment.runAction, {
144 reportFailure: false,
145 });
144 const [actionPending, setActionPending] = useState(false);146 const [actionPending, setActionPending] = useState(false);
145 const pendingRef = useRef(false);147 const pendingRef = useRef(false);
146148
apps/web/src/components/settings/ConnectionsSettings.tsx+6
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31 AuthRelayReadScope,31 AuthRelayReadScope,
32 AuthRelayWriteScope,32 AuthRelayWriteScope,
33 AuthReviewWriteScope,33 AuthReviewWriteScope,
34 AuthSourceControlWriteScope,
34 AuthStandardClientScopes,35 AuthStandardClientScopes,
35 AuthTerminalOperateScope,36 AuthTerminalOperateScope,
36 type AuthClientSession,37 type AuthClientSession,
...@@ -259,6 +260,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{...@@ -259,6 +260,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
259 title: "Use terminals",260 title: "Use terminals",
260 description: "Create terminals and send input to running shells.",261 description: "Create terminals and send input to running shells.",
261 },262 },
263 {
264 scope: AuthSourceControlWriteScope,
265 title: "Change source control",
266 description: "Commit, push, manage branches and repositories, and change pull requests.",
267 },
262 {268 {
263 scope: AuthReviewWriteScope,269 scope: AuthReviewWriteScope,
264 title: "Write reviews",270 title: "Write reviews",
apps/web/src/components/threadActionMenu.logic.test.ts+52
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7} from "./threadActionMenu.logic";7} from "./threadActionMenu.logic";
88
9const baseState: ThreadActionMenuState = {9const baseState: ThreadActionMenuState = {
10 canOperate: true,
10 branch: null,11 branch: null,
11 projectFilter: null,12 projectFilter: null,
12 isPinned: false,13 isPinned: false,
...@@ -39,6 +40,57 @@ function allIds(state: ThreadActionMenuState): string[] {...@@ -39,6 +40,57 @@ function allIds(state: ThreadActionMenuState): string[] {
39}40}
4041
41describe("buildThreadActionMenuItems", () => {42describe("buildThreadActionMenuItems", () => {
43 it.each([false, true])(
44 "disables both lifecycle directions without permission (reversed: %s)",
45 (reversed) => {
46 const items = buildThreadActionMenuItems({
47 ...baseState,
48 canOperate: false,
49 isPinned: reversed,
50 isSettled: reversed,
51 isSnoozed: reversed,
52 });
53 const expected = reversed
54 ? [
55 "unpin",
56 "unsettle",
57 "unsnooze",
58 "rename",
59 "regenerate-title",
60 "auto-settle",
61 "archive",
62 "delete",
63 ]
64 : [
65 "pin",
66 "settle",
67 "snooze",
68 "rename",
69 "regenerate-title",
70 "auto-settle",
71 "archive",
72 "delete",
73 ];
74 expect(items.filter((item) => item.disabled).map((item) => item.id)).toEqual(expected);
75 expect(
76 items.find((item) => item.id === "snooze")?.children?.every((child) => child.disabled) ??
77 true,
78 ).toBe(true);
79 },
80 );
81
82 it("preserves local actions and restores mutations after a grant", () => {
83 const denied = buildThreadActionMenuItems({ ...baseState, canOperate: false, branch: "main" });
84 expect(denied.filter((item) => !item.disabled).map((item) => item.id)).toEqual([
85 "new-thread-on-branch",
86 "mark-unread",
87 "copy",
88 "project-settings",
89 ]);
90 const allowed = buildThreadActionMenuItems({ ...baseState, canOperate: true });
91 expect(allowed.every((item) => !item.disabled)).toBe(true);
92 });
93
42 it("hides lifecycle items when the environment lacks the capabilities", () => {94 it("hides lifecycle items when the environment lacks the capabilities", () => {
43 expect(95 expect(
44 ids({96 ids({
apps/web/src/components/threadActionMenu.logic.ts+28-1
...@@ -70,6 +70,7 @@ export function buildDraftActionMenuItems(options: {...@@ -70,6 +70,7 @@ export function buildDraftActionMenuItems(options: {
70}70}
7171
72export interface ThreadActionMenuState {72export interface ThreadActionMenuState {
73 readonly canOperate: boolean;
73 readonly branch: string | null;74 readonly branch: string | null;
74 /**75 /**
75 * Project scoping for the thread list. Null on surfaces with no scoped76 * Project scoping for the thread list. Null on surfaces with no scoped
...@@ -100,6 +101,19 @@ export interface ThreadActionMenuState {...@@ -100,6 +101,19 @@ export interface ThreadActionMenuState {
100 readonly snoozePresets: ReadonlyArray<SnoozePreset>;101 readonly snoozePresets: ReadonlyArray<SnoozePreset>;
101}102}
102103
104/** Local navigation, read markers, and copying remain available to read-only clients. */
105export function threadActionRequiresOperate(action: ThreadActionMenuId): boolean {
106 return ![
107 "new-thread-on-branch",
108 "project-settings",
109 "mark-unread",
110 "copy",
111 "copy-path",
112 "copy-branch",
113 "copy-thread-id",
114 ].includes(action);
115}
116
103/**117/**
104 * Single source for the per-thread action menu: the sidebar row's right-click118 * Single source for the per-thread action menu: the sidebar row's right-click
105 * menu and the chat header menu share labels, ordering, and capability gating.119 * menu and the chat header menu share labels, ordering, and capability gating.
...@@ -108,7 +122,7 @@ export interface ThreadActionMenuState {...@@ -108,7 +122,7 @@ export interface ThreadActionMenuState {
108export function buildThreadActionMenuItems(122export function buildThreadActionMenuItems(
109 state: ThreadActionMenuState,123 state: ThreadActionMenuState,
110): ReadonlyArray<ContextMenuItem<ThreadActionMenuId>> {124): ReadonlyArray<ContextMenuItem<ThreadActionMenuId>> {
111 return [125 const items: ReadonlyArray<ContextMenuItem<ThreadActionMenuId>> = [
112 ...(state.branch126 ...(state.branch
113 ? [127 ? [
114 {128 {
...@@ -235,4 +249,17 @@ export function buildThreadActionMenuItems(...@@ -235,4 +249,17 @@ export function buildThreadActionMenuItems(
235 icon: "trash",249 icon: "trash",
236 },250 },
237 ];251 ];
252 return state.canOperate
253 ? items
254 : items.map((item) =>
255 threadActionRequiresOperate(item.id)
256 ? {
257 ...item,
258 disabled: true,
259 ...(item.children
260 ? { children: item.children.map((child) => ({ ...child, disabled: true })) }
261 : {}),
262 }
263 : item,
264 );
238}265}
apps/web/src/hooks/useThreadActionMenu.test.ts created+221
...@@ -0,0 +1,221 @@
1import {
2 AuthOrchestrationOperateScope,
3 EnvironmentId,
4 ThreadId,
5 type ContextMenuItem,
6} from "@t3tools/contracts";
7import { AsyncResult } from "effect/reactivity";
8import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
9
10import type { ThreadActionMenuId } from "../components/threadActionMenu.logic";
11
12function deferred<T>() {
13 let resolve!: (value: T) => void;
14 const promise = new Promise<T>((complete) => {
15 resolve = complete;
16 });
17 return { promise, resolve };
18}
19
20const state = vi.hoisted(() => ({
21 granted: new Set<string>(),
22 effects: [] as string[],
23 completed: deferred<void>(),
24 show: vi.fn<
25 (
26 items: ReadonlyArray<ContextMenuItem<ThreadActionMenuId>>,
27 position: { x: number; y: number },
28 ) => Promise<ThreadActionMenuId | null>
29 >(),
30}));
31
32function recordEffect(action: string) {
33 state.effects.push(action);
34 state.completed.resolve();
35}
36
37vi.mock("../components/CustomSnoozeDialog", () => ({ requestCustomSnooze: vi.fn() }));
38vi.mock("react", () => ({
39 useCallback: (callback: unknown) => callback,
40 useMemo: (factory: () => unknown) => factory(),
41}));
42vi.mock("@tanstack/react-router", () => ({
43 useRouter: () => ({ navigate: async () => recordEffect("project-settings") }),
44}));
45vi.mock("../state/session", () => ({
46 readEnvironmentScope: (environmentId: string, scope: string) =>
47 scope === AuthOrchestrationOperateScope && state.granted.has(environmentId),
48}));
49vi.mock("../state/entities", () => ({
50 readEnvironmentSupportsAutoSettleOptOut: () => true,
51 readEnvironmentSupportsPinning: () => true,
52 readEnvironmentSupportsSettlement: () => true,
53 readEnvironmentSupportsSnooze: () => true,
54 readEnvironmentSupportsTitleRegeneration: () => true,
55 readThreadShell: () => ({
56 id: "thread",
57 environmentId: "secondary",
58 projectId: "project",
59 title: "Thread",
60 branch: "main",
61 worktreePath: null,
62 runtime: null,
63 latestRun: null,
64 }),
65 useProjects: () => [{ id: "project", environmentId: "secondary" }],
66}));
67vi.mock("../state/environments", () => ({ usePrimaryEnvironmentId: () => "primary" }));
68vi.mock("../state/threads", () => ({ threadEnvironment: { updateMetadata: "metadata" } }));
69vi.mock("../state/use-atom-command", () => ({
70 useAtomCommand: () => async () => {
71 recordEffect("metadata");
72 return AsyncResult.success(undefined);
73 },
74}));
75vi.mock("../localApi", () => ({
76 readLocalApi: () => ({
77 contextMenu: { show: state.show, close: () => {} },
78 dialogs: {
79 confirm: async () => {
80 recordEffect("confirm");
81 return false;
82 },
83 },
84 }),
85}));
86vi.mock("../logicalProject", () => ({
87 deriveLogicalProjectKeyFromSettings: () => "project",
88 derivePhysicalProjectKey: () => "project",
89 selectProjectGroupingSettings: () => ({}),
90}));
91vi.mock("../sidebarProjectGrouping", () => ({
92 buildPhysicalToLogicalProjectKeyMap: () => new Map(),
93}));
94vi.mock("../uiStateStore", () => ({
95 useUiStateStore: (select: (store: unknown) => unknown) =>
96 select({
97 markThreadUnread: () => recordEffect("mark-unread"),
98 }),
99}));
100vi.mock("../components/ui/toast", () => ({
101 stackedThreadToast: (toast: unknown) => toast,
102 toastManager: { add: () => state.completed.resolve() },
103}));
104vi.mock("../components/Sidebar.snooze", () => ({
105 resolveSnoozePresets: () => [
106 { id: "hour", label: "In 1 hour", whenLabel: "3 PM", snoozedUntil: "2099-01-01T00:00:00Z" },
107 ],
108 snoozeWakeDescription: () => "later",
109}));
110vi.mock("./useCopyToClipboard", () => ({
111 useCopyToClipboard: () => ({ copyToClipboard: () => recordEffect("copy") }),
112}));
113vi.mock("./useHandleNewThread", () => ({
114 useNewThreadHandler: () => async () => recordEffect("draft"),
115}));
116vi.mock("./useSettings", () => ({
117 useClientSettings: (select: (settings: unknown) => unknown) =>
118 select({
119 confirmThreadDelete: true,
120 confirmThreadArchive: true,
121 timestampFormat: "12-hour",
122 }),
123}));
124vi.mock("./useThreadActions", () => ({
125 useThreadActions: () =>
126 Object.fromEntries(
127 [
128 "markThreadUnread",
129 "settleThread",
130 "unsettleThread",
131 "snoozeThread",
132 "unsnoozeThread",
133 "pinThread",
134 "confirmAndUnpinThread",
135 "archiveThread",
136 "deleteThread",
137 ].map((action) => [
138 action,
139 async () => {
140 recordEffect(action === "markThreadUnread" ? "mark-unread" : action);
141 return AsyncResult.success(undefined);
142 },
143 ]),
144 ),
145}));
146
147import { useThreadActionMenu } from "./useThreadActionMenu";
148
149const target = {
150 environmentId: EnvironmentId.make("secondary"),
151 threadId: ThreadId.make("thread"),
152};
153const position = { x: 10, y: 20 };
154const createMenu = () =>
155 useThreadActionMenu({
156 threadRef: target,
157 projectCwd: "/project",
158 onStartRename: () => recordEffect("rename"),
159 });
160
161beforeEach(() => {
162 state.granted = new Set(["primary"]);
163 state.effects = [];
164 state.completed = deferred<void>();
165 state.show.mockReset().mockResolvedValue(null);
166});
167
168describe("thread menu permissions", () => {
169 it("disables mutations for a denied secondary environment", () => {
170 createMenu().openMenu(position);
171 const items = state.show.mock.calls[0]![0];
172 expect(items.find((item) => item.id === "rename")?.disabled).toBe(true);
173 expect(items.find((item) => item.id === "delete")?.disabled).toBe(true);
174 expect(items.find((item) => item.id === "copy")?.disabled).not.toBe(true);
175 });
176
177 it("allows the target grant even when the primary environment is denied", () => {
178 state.granted = new Set(["secondary"]);
179 createMenu().openMenu(position);
180 expect(state.show.mock.calls[0]![0].find((item) => item.id === "rename")?.disabled).not.toBe(
181 true,
182 );
183 });
184
185 it("refreshes availability when a retained menu opener gains permission", () => {
186 const menu = createMenu();
187 menu.openMenu(position);
188 expect(state.show.mock.calls[0]![0].find((item) => item.id === "rename")?.disabled).toBe(true);
189 state.granted.add("secondary");
190 menu.openMenu(position);
191 expect(state.show.mock.calls[1]![0].find((item) => item.id === "rename")?.disabled).not.toBe(
192 true,
193 );
194 });
195
196 it.each(["rename", "regenerate-title", "delete", "pin", "settle", "archive"] as const)(
197 "%s rechecks after the native menu closes",
198 async (action) => {
199 state.granted.add("secondary");
200 const choice = deferred<ThreadActionMenuId | null>();
201 state.show.mockReturnValue(choice.promise);
202 createMenu().openMenu(position);
203 state.granted.delete("secondary");
204 choice.resolve(action);
205 await state.completed.promise;
206 expect(state.effects).toEqual([]);
207 },
208 );
209
210 it.each([
211 ["new-thread-on-branch", "draft"],
212 ["copy-thread-id", "copy"],
213 ["mark-unread", "mark-unread"],
214 ["project-settings", "project-settings"],
215 ] as const)("keeps %s available without task permission", async (action, effect) => {
216 state.show.mockResolvedValue(action);
217 createMenu().openMenu(position);
218 await state.completed.promise;
219 expect(state.effects).toEqual([effect]);
220 });
221});
apps/web/src/hooks/useThreadActionMenu.ts+20-3
...@@ -7,18 +7,24 @@ import {...@@ -7,18 +7,24 @@ import {
7 squashAtomCommandFailure,7 squashAtomCommandFailure,
8} from "@t3tools/client-runtime/state/runtime";8} from "@t3tools/client-runtime/state/runtime";
9import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";9import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
10import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";10import {
11 AuthOrchestrationOperateScope,
12 type ScopedThreadRef,
13 type ThreadId,
14} from "@t3tools/contracts";
11import { useRouter } from "@tanstack/react-router";15import { useRouter } from "@tanstack/react-router";
12import { useCallback, useMemo } from "react";16import { useCallback, useMemo } from "react";
1317
14import { resolveSnoozePresets } from "../components/Sidebar.snooze";18import { resolveSnoozePresets } from "../components/Sidebar.snooze";
15import {19import {
16 buildThreadActionMenuItems,20 buildThreadActionMenuItems,
21 threadActionRequiresOperate,
17 type ThreadActionMenuId,22 type ThreadActionMenuId,
18} from "../components/threadActionMenu.logic";23} from "../components/threadActionMenu.logic";
19import { stackedThreadToast, toastManager } from "../components/ui/toast";24import { stackedThreadToast, toastManager } from "../components/ui/toast";
20import { threadEnvironment } from "../state/threads";25import { threadEnvironment } from "../state/threads";
21import { useAtomCommand } from "../state/use-atom-command";26import { useOrchestrationCommand } from "../state/use-orchestration-command";
27import { readEnvironmentScope } from "../state/session";
22import {28import {
23 readEnvironmentSupportsAutoSettleOptOut,29 readEnvironmentSupportsAutoSettleOptOut,
24 readEnvironmentSupportsPinning,30 readEnvironmentSupportsPinning,
...@@ -94,7 +100,7 @@ export function useThreadActionMenu(input: {...@@ -94,7 +100,7 @@ export function useThreadActionMenu(input: {
94 deleteThread,100 deleteThread,
95 markThreadUnread,101 markThreadUnread,
96 } = useThreadActions();102 } = useThreadActions();
97 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {103 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
98 reportFailure: false,104 reportFailure: false,
99 });105 });
100 const handleNewThread = useNewThreadHandler();106 const handleNewThread = useNewThreadHandler();
...@@ -142,6 +148,7 @@ export function useThreadActionMenu(input: {...@@ -142,6 +148,7 @@ export function useThreadActionMenu(input: {
142 const isRegeneratingTitle = thread.titleRegeneration != null;148 const isRegeneratingTitle = thread.titleRegeneration != null;
143 const snoozePresets = resolveSnoozePresets(now, timestampFormat);149 const snoozePresets = resolveSnoozePresets(now, timestampFormat);
144 const items = buildThreadActionMenuItems({150 const items = buildThreadActionMenuItems({
151 canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope),
145 branch: thread.branch ?? null,152 branch: thread.branch ?? null,
146 projectFilter: null,153 projectFilter: null,
147 isPinned: thread.pinnedAt != null,154 isPinned: thread.pinnedAt != null,
...@@ -157,6 +164,16 @@ export function useThreadActionMenu(input: {...@@ -157,6 +164,16 @@ export function useThreadActionMenu(input: {
157 const clicked = await settlePromise(() => api.contextMenu.show(items, position));164 const clicked = await settlePromise(() => api.contextMenu.show(items, position));
158 if (clicked._tag === "Failure" || clicked.value === null) return;165 if (clicked._tag === "Failure" || clicked.value === null) return;
159 const action: ThreadActionMenuId = clicked.value;166 const action: ThreadActionMenuId = clicked.value;
167 if (
168 threadActionRequiresOperate(action) &&
169 !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)
170 ) {
171 failureToast(
172 "Thread action unavailable",
173 new Error("This connection cannot change threads."),
174 );
175 return;
176 }
160 if (action.startsWith("snooze:")) {177 if (action.startsWith("snooze:")) {
161 const preset =178 const preset =
162 action === "snooze:custom"179 action === "snooze:custom"
apps/web/src/hooks/useThreadActions.permissions.test.ts created+354
...@@ -0,0 +1,354 @@
1import {
2 AuthOrchestrationOperateScope,
3 AuthSourceControlWriteScope,
4 EnvironmentAuthorizationError,
5 EnvironmentId,
6 ProjectId,
7 ThreadId,
8 type ScopedThreadRef,
9} from "@t3tools/contracts";
10import * as Cause from "effect/Cause";
11import { AsyncResult } from "effect/reactivity";
12import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
13
14function deferred<T>() {
15 let resolve!: (value: T) => void;
16 const promise = new Promise<T>((complete) => {
17 resolve = complete;
18 });
19 return { promise, resolve };
20}
21
22const state = vi.hoisted(() => ({
23 scopes: new Map<string, Set<string>>(),
24 threads: [] as {
25 environmentId: EnvironmentId;
26 id: ThreadId;
27 projectId: ProjectId;
28 title: string;
29 worktreePath: string | null;
30 session: { status: "ready" | "stopped" } | null;
31 latestTurn: null;
32 }[],
33 requests: [] as { action: string; environmentId: string; input: { threadId?: string } }[],
34 localEffects: [] as string[],
35 confirm: vi.fn<(message: string) => Promise<boolean>>(),
36 toasts: [] as string[],
37 afterRequest: undefined as ((action: string) => void) | undefined,
38 sessionLookupFails: false,
39}));
40
41vi.mock("react", () => ({
42 useCallback: (callback: unknown) => callback,
43 useMemo: (factory: () => unknown) => factory(),
44 useRef: (current: unknown) => ({ current }),
45}));
46vi.mock("@tanstack/react-router", () => ({
47 useRouter: () => ({ state: { matches: [] }, navigate: async () => {} }),
48}));
49vi.mock("../state/session", () => ({
50 environmentSession: { sessionStateAtom: "session" },
51 readEnvironmentScope: (environmentId: string, scope: string) =>
52 state.scopes.get(environmentId)?.has(scope) === true,
53}));
54vi.mock("../state/use-atom-command", () => ({
55 useAtomCommand:
56 (action: string) =>
57 async (request: { environmentId: string; input: { threadId?: string } }) => {
58 state.requests.push({ action, ...request });
59 const scope =
60 action === "removeWorktree" ? AuthSourceControlWriteScope : AuthOrchestrationOperateScope;
61 if (!state.scopes.get(request.environmentId)?.has(scope)) {
62 return AsyncResult.failure(Cause.fail(new Error("Server denied the request")));
63 }
64 state.afterRequest?.(action);
65 return AsyncResult.success(undefined);
66 },
67}));
68vi.mock("../state/use-atom-query-runner", () => ({
69 useAtomQueryRunner: () => async (environmentId: string) =>
70 state.sessionLookupFails
71 ? AsyncResult.failure(Cause.fail(new Error("Session lookup failed")))
72 : AsyncResult.success({
73 authenticated: true,
74 scopes: [...(state.scopes.get(environmentId) ?? [])],
75 }),
76}));
77vi.mock("../state/threads", () => ({
78 threadEnvironment: Object.fromEntries(
79 [
80 "archive",
81 "unarchive",
82 "delete",
83 "settle",
84 "unsettle",
85 "pin",
86 "unpin",
87 "reorderPin",
88 "snooze",
89 "unsnooze",
90 "stopSession",
91 ].map((action) => [action, action]),
92 ),
93}));
94vi.mock("../state/vcs", () => ({
95 vcsEnvironment: { removeWorktree: "removeWorktree", refreshStatus: "refreshStatus" },
96}));
97vi.mock("../state/entities", () => ({
98 readEnvironmentSupportsPinning: () => true,
99 readEnvironmentSupportsPinReorder: () => true,
100 readEnvironmentSupportsSettlement: () => true,
101 readEnvironmentSupportsSnooze: () => true,
102 readThreadShell: (ref: ScopedThreadRef) =>
103 state.threads.find(
104 (thread) => thread.environmentId === ref.environmentId && thread.id === ref.threadId,
105 ) ?? null,
106 readThreadShells: () => state.threads,
107 readEnvironmentThreadRefs: (environmentId: EnvironmentId) =>
108 state.threads
109 .filter((thread) => thread.environmentId === environmentId)
110 .map((thread) => ({ environmentId, threadId: thread.id })),
111 readProject: () => ({ workspaceRoot: "/project" }),
112}));
113vi.mock("../components/Sidebar.logic", () => ({
114 getFallbackThreadIdAfterDelete: () => null,
115 pinOrderKeyBetween: () => "a",
116}));
117vi.mock("../composerDraftStore", () => ({
118 useComposerDraftStore: (select: (store: unknown) => unknown) =>
119 select({
120 clearDraftThread: () => state.localEffects.push("clear-draft"),
121 clearProjectDraftThreadById: () => state.localEffects.push("clear-project-draft"),
122 }),
123}));
124vi.mock("../terminalUiStateStore", () => ({
125 useTerminalUiStateStore: (select: (store: unknown) => unknown) =>
126 select({
127 clearTerminalUiState: () => state.localEffects.push("clear-terminal-ui"),
128 }),
129}));
130vi.mock("../uiStateStore", () => ({
131 useUiStateStore: (select: (store: unknown) => unknown) =>
132 select({
133 markThreadVisited: () => state.localEffects.push("mark-visited"),
134 }),
135}));
136vi.mock("../lib/archivedThreadsState", () => ({
137 refreshArchivedThreadsForEnvironment: () => state.localEffects.push("refresh-archive"),
138}));
139vi.mock("../lib/composerDraftUploads", () => ({
140 releaseComposerDraftUploads: () => state.localEffects.push("release-uploads"),
141}));
142vi.mock("../localApi", () => ({
143 readLocalApi: () => ({ dialogs: { confirm: state.confirm } }),
144}));
145vi.mock("../threadRoutes", () => ({
146 resolveThreadRouteRef: () => null,
147 buildThreadRouteParams: (ref: ScopedThreadRef) => ref,
148}));
149vi.mock("../components/ui/toast", () => ({
150 stackedThreadToast: (toast: unknown) => toast,
151 toastManager: {
152 add: (toast: { title?: string }) => {
153 if (toast.title) state.toasts.push(toast.title);
154 },
155 },
156}));
157vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => async () => {} }));
158vi.mock("./useSettings", () => ({
159 useClientSettings: (select: (settings: unknown) => unknown) =>
160 select({
161 sidebarThreadSortOrder: "createdAt",
162 confirmThreadDelete: true,
163 confirmThreadUnpin: true,
164 }),
165}));
166
167import { useThreadActions } from "./useThreadActions";
168
169const primary = EnvironmentId.make("primary");
170const secondary = EnvironmentId.make("secondary");
171const target = { environmentId: secondary, threadId: ThreadId.make("thread") };
172type ThreadActions = ReturnType<typeof useThreadActions>;
173const operations = [
174 {
175 name: "archive",
176 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.archiveThread(ref),
177 },
178 {
179 name: "unarchive",
180 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unarchiveThread(ref),
181 },
182 {
183 name: "settle",
184 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.settleThread(ref),
185 },
186 {
187 name: "unsettle",
188 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unsettleThread(ref),
189 },
190 {
191 name: "snooze",
192 run: (actions: ThreadActions, ref: ScopedThreadRef) =>
193 actions.snoozeThread(ref, "2099-01-01T00:00:00Z"),
194 },
195 {
196 name: "unsnooze",
197 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unsnoozeThread(ref),
198 },
199 { name: "pin", run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.pinThread(ref) },
200 {
201 name: "unpin",
202 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unpinThread(ref),
203 },
204 {
205 name: "reorderPin",
206 run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.reorderPinnedThread(ref, "b"),
207 },
208] as const;
209
210beforeEach(() => {
211 state.scopes = new Map([
212 [primary, new Set([AuthOrchestrationOperateScope])],
213 [secondary, new Set<string>()],
214 ]);
215 state.threads = [
216 {
217 environmentId: secondary,
218 id: target.threadId,
219 projectId: ProjectId.make("project"),
220 title: "Thread",
221 worktreePath: null,
222 session: null,
223 latestTurn: null,
224 },
225 ];
226 state.requests = [];
227 state.localEffects = [];
228 state.confirm.mockReset().mockResolvedValue(true);
229 state.toasts = [];
230 state.afterRequest = undefined;
231 state.sessionLookupFails = false;
232});
233
234describe("thread action permissions", () => {
235 it.each(operations)("$name requires the target environment's grant", async ({ run }) => {
236 const result = await run(useThreadActions(), target);
237 expect(result._tag).toBe("Failure");
238 expect(state.requests).toEqual([]);
239 expect(state.localEffects).toEqual([]);
240 });
241
242 it.each(operations)("$name rechecks a retained callback after revocation", async ({ run }) => {
243 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
244 const actions = useThreadActions();
245 state.scopes.get(secondary)!.clear();
246 await run(actions, target);
247 expect(state.requests).toEqual([]);
248 });
249
250 it.each(operations)(
251 "$name works after the target gains only task permission",
252 async ({ name, run }) => {
253 const actions = useThreadActions();
254 state.scopes.get(primary)!.clear();
255 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
256 expect((await run(actions, target))._tag).toBe("Success");
257 expect(state.requests).toEqual([
258 expect.objectContaining({ action: name, environmentId: secondary }),
259 ]);
260 },
261 );
262
263 it.each(["confirmAndDeleteThread", "confirmAndUnpinThread"] as const)(
264 "%s blocks a forbidden confirmation",
265 async (action) => {
266 await useThreadActions()[action](target);
267 expect(state.confirm).not.toHaveBeenCalled();
268 expect(state.requests).toEqual([]);
269 },
270 );
271
272 it.each(["confirmAndDeleteThread", "confirmAndUnpinThread"] as const)(
273 "%s rechecks after the confirmation",
274 async (action) => {
275 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
276 const confirmation = deferred<boolean>();
277 state.confirm.mockReturnValue(confirmation.promise);
278 const result = useThreadActions()[action](target);
279 expect(state.confirm).toHaveBeenCalledOnce();
280 state.scopes.get(secondary)!.clear();
281 confirmation.resolve(true);
282 await result;
283 expect(state.requests).toEqual([]);
284 expect(state.localEffects).toEqual([]);
285 },
286 );
287
288 it("identifies the missing task scope when thread deletion is denied", async () => {
289 const result = await useThreadActions().deleteThread(target);
290
291 expect(result._tag).toBe("Failure");
292 if (result._tag !== "Failure") throw new Error("Expected permission denial");
293 const error = Cause.squash<unknown>(result.cause);
294 expect(error).toBeInstanceOf(EnvironmentAuthorizationError);
295 expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope });
296 expect(state.requests).toEqual([]);
297 expect(state.localEffects).toEqual([]);
298 });
299
300 it("deletes an archived thread only with its own environment's grant", async () => {
301 state.threads = [];
302 const actions = useThreadActions();
303 await actions.deleteThread(target);
304 expect(state.requests).toEqual([]);
305 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
306 expect((await actions.deleteThread(target))._tag).toBe("Success");
307 expect(state.requests).toEqual([
308 expect.objectContaining({ action: "delete", environmentId: secondary }),
309 ]);
310 });
311
312 it("stops before delete and local cleanup when permission is revoked during session stop", async () => {
313 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
314 state.threads[0]!.session = { status: "ready" };
315 state.afterRequest = () => state.scopes.get(secondary)!.clear();
316 expect((await useThreadActions().deleteThread(target))._tag).toBe("Failure");
317 expect(state.requests.map((request) => request.action)).toEqual(["stopSession"]);
318 expect(state.localEffects).toEqual([]);
319 });
320
321 it.each([
322 { reason: "without source-control permission", sessionLookupFails: false },
323 { reason: "when the permission lookup fails", sessionLookupFails: true },
324 ])("deletes a worktree thread and keeps its worktree $reason", async ({ sessionLookupFails }) => {
325 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
326 state.threads[0]!.worktreePath = "/worktrees/thread";
327 state.sessionLookupFails = sessionLookupFails;
328 expect((await useThreadActions().deleteThread(target))._tag).toBe("Success");
329 expect(state.confirm).not.toHaveBeenCalled();
330 expect(state.requests.map((request) => request.action)).toEqual(["stopSession", "delete"]);
331 expect(state.localEffects).toContain("clear-terminal-ui");
332 });
333
334 it("does not request worktree removal after its grant is revoked during delete", async () => {
335 state.scopes
336 .get(secondary)!
337 .add(AuthOrchestrationOperateScope)
338 .add(AuthSourceControlWriteScope);
339 state.threads[0]!.worktreePath = "/worktrees/thread";
340 state.afterRequest = () => state.scopes.get(secondary)!.delete(AuthSourceControlWriteScope);
341 const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
342 try {
343 // The thread is already gone, so cleanup reports itself in a toast
344 // instead of failing the deletion.
345 const result = await useThreadActions().deleteThread(target);
346 expect(result._tag).toBe("Success");
347 expect(state.requests.map((request) => request.action)).toEqual(["stopSession", "delete"]);
348 expect(state.toasts).toContain("Failed to delete worktree");
349 expect(state.localEffects).toContain("clear-terminal-ui");
350 } finally {
351 consoleError.mockRestore();
352 }
353 });
354});
apps/web/src/hooks/useThreadActions.ts+81-34
...@@ -7,7 +7,14 @@ import {...@@ -7,7 +7,14 @@ import {
7import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";7import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
8import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";8import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
9import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models";9import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models";
10import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";10import {
11 AuthOrchestrationOperateScope,
12 AuthSourceControlWriteScope,
13 EnvironmentAuthorizationError,
14 EnvironmentId,
15 type ScopedThreadRef,
16 ThreadId,
17} from "@t3tools/contracts";
11import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";18import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
12import * as Cause from "effect/Cause";19import * as Cause from "effect/Cause";
13import * as Schema from "effect/Schema";20import * as Schema from "effect/Schema";
...@@ -17,6 +24,7 @@ import { useCallback, useMemo, useRef } from "react";...@@ -17,6 +24,7 @@ import { useCallback, useMemo, useRef } from "react";
1724
18import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";25import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";
19import { useComposerDraftStore } from "../composerDraftStore";26import { useComposerDraftStore } from "../composerDraftStore";
27import { environmentSession, readEnvironmentScope } from "../state/session";
20import { terminalEnvironment } from "../state/terminal";28import { terminalEnvironment } from "../state/terminal";
21import { appAtomRegistry } from "../rpc/atomRegistry";29import { appAtomRegistry } from "../rpc/atomRegistry";
22import { environmentServerConfigsAtom } from "../state/server";30import { environmentServerConfigsAtom } from "../state/server";
...@@ -49,6 +57,8 @@ import { useClientSettings } from "./useSettings";...@@ -49,6 +57,8 @@ import { useClientSettings } from "./useSettings";
49import * as ThreadUndo from "./threadUndo";57import * as ThreadUndo from "./threadUndo";
50import { showThreadUndoNotice } from "./showThreadUndoNotice";58import { showThreadUndoNotice } from "./showThreadUndoNotice";
51import { useAtomCommand } from "../state/use-atom-command";59import { useAtomCommand } from "../state/use-atom-command";
60import { useOrchestrationCommand } from "../state/use-orchestration-command";
61import { useAtomQueryRunner } from "../state/use-atom-query-runner";
5262
53export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()(63export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()(
54 "ThreadArchiveBlockedError",64 "ThreadArchiveBlockedError",
...@@ -243,49 +253,64 @@ export function useAcknowledgeThreadWoke() {...@@ -243,49 +253,64 @@ export function useAcknowledgeThreadWoke() {
243 );253 );
244}254}
245255
256function threadOperationFailure(target: ScopedThreadRef) {
257 return readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope)
258 ? null
259 : AsyncResult.failure(
260 Cause.fail(
261 new EnvironmentAuthorizationError({
262 message: "This connection cannot change threads.",
263 requiredScope: AuthOrchestrationOperateScope,
264 }),
265 ),
266 );
267}
268
246export function useThreadActions() {269export function useThreadActions() {
247 const closeTerminal = useAtomCommand(terminalEnvironment.close);270 const archiveThreadMutation = useOrchestrationCommand(threadEnvironment.archive, {
248 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
249 reportFailure: false,271 reportFailure: false,
250 });272 });
251 const unarchiveThreadMutation = useAtomCommand(threadEnvironment.unarchive, {273 const unarchiveThreadMutation = useOrchestrationCommand(threadEnvironment.unarchive, {
252 reportFailure: false,274 reportFailure: false,
253 });275 });
254 const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, {276 const deleteThreadMutation = useOrchestrationCommand(threadEnvironment.delete, {
255 reportFailure: false,277 reportFailure: false,
256 });278 });
257 const settleThreadMutation = useAtomCommand(threadEnvironment.settle, {279 const settleThreadMutation = useOrchestrationCommand(threadEnvironment.settle, {
258 reportFailure: false,280 reportFailure: false,
259 });281 });
260 const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, {282 const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, {
261 reportFailure: false,283 reportFailure: false,
262 });284 });
263 const pinThreadMutation = useAtomCommand(threadEnvironment.pin, {285 const pinThreadMutation = useOrchestrationCommand(threadEnvironment.pin, {
264 reportFailure: false,286 reportFailure: false,
265 });287 });
266 const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, {288 const unpinThreadMutation = useOrchestrationCommand(threadEnvironment.unpin, {
267 reportFailure: false,289 reportFailure: false,
268 });290 });
269 const setThreadAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, {291 const setThreadAutoSettleMutation = useOrchestrationCommand(threadEnvironment.setAutoSettle, {
270 reportFailure: false,292 reportFailure: false,
271 });293 });
272 const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, {294 const reorderPinnedThreadMutation = useOrchestrationCommand(threadEnvironment.reorderPin, {
273 reportFailure: false,295 reportFailure: false,
274 });296 });
275 const reorderActiveThreadMutation = useAtomCommand(threadEnvironment.reorderActive, {297 const reorderActiveThreadMutation = useOrchestrationCommand(threadEnvironment.reorderActive, {
276 reportFailure: false,298 reportFailure: false,
277 });299 });
278 const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, {300 const snoozeThreadMutation = useOrchestrationCommand(threadEnvironment.snooze, {
279 reportFailure: false,301 reportFailure: false,
280 });302 });
281 const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, {303 const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, {
282 reportFailure: false,304 reportFailure: false,
283 });305 });
284 const markThreadUnread = useMarkThreadUnread();306 const markThreadUnread = useMarkThreadUnread();
285 const stopThreadSession = useAtomCommand(threadEnvironment.stopSession);307 const stopThreadSession = useOrchestrationCommand(threadEnvironment.stopSession);
286 const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, {308 const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, {
287 reportFailure: false,309 reportFailure: false,
288 });310 });
311 const loadSessionState = useAtomQueryRunner(environmentSession.sessionStateAtom, {
312 reportFailure: false,
313 });
289 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, {314 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, {
290 reportFailure: false,315 reportFailure: false,
291 });316 });
...@@ -412,6 +437,8 @@ export function useThreadActions() {...@@ -412,6 +437,8 @@ export function useThreadActions() {
412437
413 const deleteThread = useCallback(438 const deleteThread = useCallback(
414 async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet<string> } = {}) => {439 async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet<string> } = {}) => {
440 const permissionFailure = threadOperationFailure(target);
441 if (permissionFailure) return permissionFailure;
415 const resolved = resolveThreadTarget(target);442 const resolved = resolveThreadTarget(target);
416 if (!resolved) {443 if (!resolved) {
417 // Thread not in main store (e.g. archived thread) — dispatch delete directly.444 // Thread not in main store (e.g. archived thread) — dispatch delete directly.
...@@ -456,13 +483,22 @@ export function useThreadActions() {...@@ -456,13 +483,22 @@ export function useThreadActions() {
456 const environmentConfig = appAtomRegistry483 const environmentConfig = appAtomRegistry
457 .get(environmentServerConfigsAtom)484 .get(environmentServerConfigsAtom)
458 .get(threadRef.environmentId);485 .get(threadRef.environmentId);
459 // A Scratch thread's folder is not a git worktree, and deleting the486 const localApi = readLocalApi();
460 // thread keeps its files.487 let canDeleteWorktree = false;
461 const canDeleteWorktree =488 if (
462 orphanedWorktreePath !== null &&489 orphanedWorktreePath !== null &&
463 threadProject !== null &&490 threadProject !== null &&
464 !isScratchProject(threadProject, environmentConfig?.scratchWorkspaceRoot);491 !isScratchProject(threadProject, environmentConfig?.scratchWorkspaceRoot) &&
465 const localApi = readLocalApi();492 localApi
493 ) {
494 const sessionResult = await loadSessionState(threadRef.environmentId);
495 const permissionFailure = threadOperationFailure(threadRef);
496 if (permissionFailure) return permissionFailure;
497 canDeleteWorktree =
498 sessionResult._tag === "Success" &&
499 sessionResult.value.authenticated &&
500 sessionResult.value.scopes?.includes(AuthSourceControlWriteScope) === true;
501 }
466 let shouldDeleteWorktree = false;502 let shouldDeleteWorktree = false;
467 const environmentSettings = environmentConfig?.settings;503 const environmentSettings = environmentConfig?.settings;
468 const automaticWorktreeCleanup = environmentSettings504 const automaticWorktreeCleanup = environmentSettings
...@@ -493,11 +529,6 @@ export function useThreadActions() {...@@ -493,11 +529,6 @@ export function useThreadActions() {
493 });529 });
494 }530 }
495531
496 await closeTerminal({
497 environmentId: threadRef.environmentId,
498 input: { threadId: threadRef.threadId, deleteHistory: true },
499 });
500
501 const deletedThreadIds = deletedIds ?? new Set<ThreadId>();532 const deletedThreadIds = deletedIds ?? new Set<ThreadId>();
502 const currentRouteThreadRef = getCurrentRouteThreadRef();533 const currentRouteThreadRef = getCurrentRouteThreadRef();
503 const shouldNavigateToFallback =534 const shouldNavigateToFallback =
...@@ -546,14 +577,26 @@ export function useThreadActions() {...@@ -546,14 +577,26 @@ export function useThreadActions() {
546 return deleteResult;577 return deleteResult;
547 }578 }
548579
549 const removeResult = await removeWorktree({580 const removeResult = readEnvironmentScope(
550 environmentId: threadRef.environmentId,581 threadRef.environmentId,
551 input: {582 AuthSourceControlWriteScope,
552 cwd: threadProject.workspaceRoot,583 )
553 path: orphanedWorktreePath,584 ? await removeWorktree({
554 force: true,585 environmentId: threadRef.environmentId,
555 },586 input: {
556 });587 cwd: threadProject.workspaceRoot,
588 path: orphanedWorktreePath,
589 force: true,
590 },
591 })
592 : AsyncResult.failure(
593 Cause.fail(
594 new EnvironmentAuthorizationError({
595 message: "This connection can no longer remove worktrees.",
596 requiredScope: AuthSourceControlWriteScope,
597 }),
598 ),
599 );
557 const refreshResult =600 const refreshResult =
558 removeResult._tag === "Success"601 removeResult._tag === "Success"
559 ? await refreshVcsStatus({602 ? await refreshVcsStatus({
...@@ -597,9 +640,9 @@ export function useThreadActions() {...@@ -597,9 +640,9 @@ export function useThreadActions() {
597 clearComposerDraftForThread,640 clearComposerDraftForThread,
598 clearProjectDraftThreadById,641 clearProjectDraftThreadById,
599 clearTerminalUiState,642 clearTerminalUiState,
600 closeTerminal,
601 deleteThreadMutation,643 deleteThreadMutation,
602 getCurrentRouteThreadRef,644 getCurrentRouteThreadRef,
645 loadSessionState,
603 refreshVcsStatus,646 refreshVcsStatus,
604 removeWorktree,647 removeWorktree,
605 router,648 router,
...@@ -797,6 +840,8 @@ export function useThreadActions() {...@@ -797,6 +840,8 @@ export function useThreadActions() {
797840
798 const confirmAndUnpinThread = useCallback(841 const confirmAndUnpinThread = useCallback(
799 async (target: ScopedThreadRef) => {842 async (target: ScopedThreadRef) => {
843 const permissionFailure = threadOperationFailure(target);
844 if (permissionFailure) return permissionFailure;
800 const localApi = readLocalApi();845 const localApi = readLocalApi();
801 const resolved = resolveThreadTarget(target);846 const resolved = resolveThreadTarget(target);
802 const confirmationResult = await requestThreadUnpinConfirmation({847 const confirmationResult = await requestThreadUnpinConfirmation({
...@@ -930,6 +975,8 @@ export function useThreadActions() {...@@ -930,6 +975,8 @@ export function useThreadActions() {
930975
931 const confirmAndDeleteThread = useCallback(976 const confirmAndDeleteThread = useCallback(
932 async (target: ScopedThreadRef) => {977 async (target: ScopedThreadRef) => {
978 const permissionFailure = threadOperationFailure(target);
979 if (permissionFailure) return permissionFailure;
933 const localApi = readLocalApi();980 const localApi = readLocalApi();
934 const resolved = resolveThreadTarget(target);981 const resolved = resolveThreadTarget(target);
935982
apps/web/src/hooks/useThreadActions.undo.test.ts+5
...@@ -20,6 +20,11 @@ const router = vi.hoisted(() => ({...@@ -20,6 +20,11 @@ const router = vi.hoisted(() => ({
20 navigate: vi.fn(async () => {}),20 navigate: vi.fn(async () => {}),
21 state: { matches: [{ params: {} as Record<string, string> }] },21 state: { matches: [{ params: {} as Record<string, string> }] },
22}));22}));
23vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
24vi.mock("../state/session", async (original) => ({
25 ...(await original<typeof import("../state/session")>()),
26 readEnvironmentScope: () => true,
27}));
23vi.mock("react", async (original) => ({28vi.mock("react", async (original) => ({
24 ...(await original<typeof import("react")>()),29 ...(await original<typeof import("react")>()),
25 useCallback: (callback: unknown) => callback,30 useCallback: (callback: unknown) => callback,
apps/web/src/state/sourceControlActions.test.ts created+209
...@@ -0,0 +1,209 @@
1import {
2 AuthOrchestrationOperateScope,
3 AuthSourceControlWriteScope,
4 EnvironmentId,
5 ThreadId,
6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import { AsyncResult } from "effect/reactivity";
9import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
10
11const state = vi.hoisted(() => ({
12 scopes: new Map<string, Set<string>>(),
13 requests: [] as { action: string; environmentId: string | null }[],
14}));
15
16vi.mock("react", () => ({ useCallback: (callback: unknown) => callback }));
17vi.mock("@effect/atom-react", () => ({
18 useAtomValue: () => ({ operation: null, error: null, isRunning: false }),
19}));
20vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} }));
21vi.mock("./session", () => ({
22 useEnvironmentScope: (environmentId: string, scope: string) =>
23 state.scopes.get(environmentId)?.has(scope) === true,
24 readEnvironmentScope: (environmentId: string, scope: string) =>
25 state.scopes.get(environmentId)?.has(scope) === true,
26}));
27vi.mock("./query", () => ({ useEnvironmentQuery: () => ({ refresh: () => {} }) }));
28vi.mock("./use-atom-command", () => ({
29 useAtomCommand:
30 (command: { action: string; environmentId?: string }) =>
31 async (input: { environmentId?: string }) => {
32 state.requests.push({
33 action: command.action,
34 environmentId: input.environmentId ?? command.environmentId ?? null,
35 });
36 return AsyncResult.success({});
37 },
38}));
39vi.mock("./vcs", () => ({
40 vcsEnvironment: { init: { action: "init" }, pull: { action: "pull" }, status: () => "status" },
41 vcsActionManager: {
42 stateAtom: () => "state",
43 resetError: () => {},
44 track: (_registry: unknown, _target: unknown, _input: unknown, execute: () => unknown) =>
45 execute(),
46 runStackedAction: (scope: { environmentId: string }) => ({ action: "stack", ...scope }),
47 },
48}));
49vi.mock("./sourceControl", () => ({
50 sourceControlEnvironment: { publishRepository: { action: "publish" } },
51}));
52vi.mock("./git", () => ({
53 gitEnvironment: { preparePullRequestThread: { action: "prepare" } },
54}));
55
56import {
57 useGitStackedAction,
58 usePreparePullRequestThreadAction,
59 useSourceControlPublishRepositoryAction,
60 useVcsInitAction,
61 useVcsPullAction,
62} from "./sourceControlActions";
63
64const primary = EnvironmentId.make("primary");
65const secondary = EnvironmentId.make("secondary");
66const scope = { environmentId: secondary, cwd: "/repo" };
67const threadId = ThreadId.make("thread");
68const cases = [
69 {
70 name: "init",
71 create: () => {
72 const action = useVcsInitAction(scope);
73 return { isAllowed: action.isAllowed, run: () => action.run() };
74 },
75 },
76 {
77 name: "pull",
78 create: () => {
79 const action = useVcsPullAction(scope);
80 return { isAllowed: action.isAllowed, run: () => action.run() };
81 },
82 },
83 {
84 name: "stack",
85 create: () => {
86 const action = useGitStackedAction(scope);
87 return {
88 isAllowed: action.isAllowed,
89 run: () => action.run({ actionId: "action", action: "commit" }),
90 };
91 },
92 },
93 {
94 name: "publish",
95 create: () => {
96 const action = useSourceControlPublishRepositoryAction(scope);
97 return {
98 isAllowed: action.isAllowed,
99 run: () =>
100 action.run({
101 provider: "github",
102 repository: "owner/repo",
103 visibility: "private",
104 remoteName: "origin",
105 protocol: "ssh",
106 }),
107 };
108 },
109 },
110 {
111 name: "prepare",
112 create: () => {
113 const action = usePreparePullRequestThreadAction(scope);
114 return {
115 isAllowed: action.isAllowed,
116 run: () => action.run({ reference: "1", mode: "local" }),
117 };
118 },
119 },
120] as const;
121
122beforeEach(() => {
123 state.scopes = new Map([
124 [primary, new Set([AuthSourceControlWriteScope, AuthOrchestrationOperateScope])],
125 [secondary, new Set<string>()],
126 ]);
127 state.requests = [];
128});
129
130describe("source control callback grants", () => {
131 it.each(cases)("$name uses the selected environment's permission", async ({ create }) => {
132 const action = create();
133 expect(action.isAllowed).toBe(false);
134 expect((await action.run())._tag).toBe("Failure");
135 expect(state.requests).toEqual([]);
136 });
137
138 it.each(cases)("$name rejects a retained callback after revocation", async ({ create }) => {
139 state.scopes.get(secondary)!.add(AuthSourceControlWriteScope);
140 const action = create();
141 expect(action.isAllowed).toBe(true);
142 state.scopes.get(secondary)!.clear();
143 expect((await action.run())._tag).toBe("Failure");
144 expect(state.requests).toEqual([]);
145 expect(create().isAllowed).toBe(false);
146 });
147
148 it.each(cases)("$name accepts a retained callback after a grant", async ({ name, create }) => {
149 const action = create();
150 expect(action.isAllowed).toBe(false);
151 state.scopes.get(primary)!.clear();
152 state.scopes.get(secondary)!.add(AuthSourceControlWriteScope);
153 expect((await action.run())._tag).toBe("Success");
154 expect(state.requests).toEqual([{ action: name, environmentId: secondary }]);
155 expect(create().isAllowed).toBe(true);
156 });
157});
158
159describe("pull request worktree attachment permission", () => {
160 it.each([
161 ["local", threadId, false, true],
162 ["worktree", undefined, false, true],
163 ["worktree", threadId, false, false],
164 ["worktree", threadId, true, true],
165 ] as const)(
166 "mode %s / thread %s / task grant %s",
167 async (mode, attachedThreadId, operate, allowed) => {
168 state.scopes.get(secondary)!.add(AuthSourceControlWriteScope);
169 if (operate) state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
170 const result = await usePreparePullRequestThreadAction(scope).run({
171 reference: "1",
172 mode,
173 ...(attachedThreadId !== undefined ? { threadId: attachedThreadId } : {}),
174 });
175 expect(result._tag).toBe(allowed ? "Success" : "Failure");
176 expect(state.requests).toHaveLength(allowed ? 1 : 0);
177 if (result._tag === "Failure") {
178 expect(Cause.squash(result.cause)).toMatchObject({
179 requiredScope: AuthOrchestrationOperateScope,
180 });
181 }
182 },
183 );
184
185 it.each([false, true])(
186 "reads the fresh task grant before attaching a worktree: %s",
187 async (allowed) => {
188 state.scopes.get(secondary)!.add(AuthSourceControlWriteScope);
189 if (!allowed) state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
190 const action = usePreparePullRequestThreadAction(scope);
191 if (allowed) state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
192 else state.scopes.get(secondary)!.delete(AuthOrchestrationOperateScope);
193 const result = await action.run({ reference: "1", mode: "worktree", threadId });
194 expect(result._tag).toBe(allowed ? "Success" : "Failure");
195 expect(state.requests).toHaveLength(allowed ? 1 : 0);
196 },
197 );
198
199 it("still requires source-control permission when the task grant is present", async () => {
200 state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
201 const result = await usePreparePullRequestThreadAction(scope).run({
202 reference: "1",
203 mode: "worktree",
204 threadId,
205 });
206 expect(result._tag).toBe("Failure");
207 expect(state.requests).toEqual([]);
208 });
209});
apps/web/src/state/sourceControlActions.ts+44-7
...@@ -9,12 +9,15 @@ import {...@@ -9,12 +9,15 @@ import {
9 type VcsActionOperation,9 type VcsActionOperation,
10 type RunVcsStackedActionInput,10 type RunVcsStackedActionInput,
11} from "@t3tools/client-runtime/state/vcs";11} from "@t3tools/client-runtime/state/vcs";
12import type {12import {
13 EnvironmentId,13 AuthOrchestrationOperateScope,
14 GitResolvePullRequestResult,14 AuthSourceControlWriteScope,
15 SourceControlCloneProtocol,15 EnvironmentAuthorizationError,
16 SourceControlRepositoryVisibility,16 type EnvironmentId,
17 ThreadId,17 type GitResolvePullRequestResult,
18 type SourceControlCloneProtocol,
19 type SourceControlRepositoryVisibility,
20 type ThreadId,
18} from "@t3tools/contracts";21} from "@t3tools/contracts";
19import * as Cause from "effect/Cause";22import * as Cause from "effect/Cause";
20import * as Option from "effect/Option";23import * as Option from "effect/Option";
...@@ -24,6 +27,7 @@ import { useCallback } from "react";...@@ -24,6 +27,7 @@ import { useCallback } from "react";
24import { appAtomRegistry } from "../rpc/atomRegistry";27import { appAtomRegistry } from "../rpc/atomRegistry";
25import { gitEnvironment } from "./git";28import { gitEnvironment } from "./git";
26import { useEnvironmentQuery } from "./query";29import { useEnvironmentQuery } from "./query";
30import { readEnvironmentScope, useEnvironmentScope } from "./session";
27import { sourceControlEnvironment } from "./sourceControl";31import { sourceControlEnvironment } from "./sourceControl";
28import { useAtomCommand } from "./use-atom-command";32import { useAtomCommand } from "./use-atom-command";
29import { vcsActionManager, vcsEnvironment } from "./vcs";33import { vcsActionManager, vcsEnvironment } from "./vcs";
...@@ -45,11 +49,15 @@ interface SourceControlActionState<...@@ -45,11 +49,15 @@ interface SourceControlActionState<
45 R extends AtomCommandResult<unknown, unknown>,49 R extends AtomCommandResult<unknown, unknown>,
46> {50> {
47 readonly isPending: boolean;51 readonly isPending: boolean;
52 readonly isAllowed: boolean;
48 readonly error: unknown;53 readonly error: unknown;
49 readonly run: (54 readonly run: (
50 ...args: TArgs55 ...args: TArgs
51 ) => Promise<56 ) => Promise<
52 AtomCommandResult<AtomCommandSuccess<R>, AtomCommandFailure<R> | VcsActionUnavailableError>57 AtomCommandResult<
58 AtomCommandSuccess<R>,
59 AtomCommandFailure<R> | VcsActionUnavailableError | EnvironmentAuthorizationError
60 >
53 >;61 >;
54 readonly resetError: () => void;62 readonly resetError: () => void;
55}63}
...@@ -73,6 +81,7 @@ function useAction<...@@ -73,6 +81,7 @@ function useAction<
73 readonly onSuccess?: () => void;81 readonly onSuccess?: () => void;
74 readonly managedExternally?: boolean;82 readonly managedExternally?: boolean;
75}): SourceControlActionState<TArgs, R> {83}): SourceControlActionState<TArgs, R> {
84 const isAllowed = useEnvironmentScope(input.scope.environmentId, AuthSourceControlWriteScope);
76 const operation = ACTION_OPERATION[input.kind];85 const operation = ACTION_OPERATION[input.kind];
77 const state = useAtomValue(vcsActionManager.stateAtom(input.scope));86 const state = useAtomValue(vcsActionManager.stateAtom(input.scope));
78 const ownsState = state.operation === operation;87 const ownsState = state.operation === operation;
...@@ -83,6 +92,19 @@ function useAction<...@@ -83,6 +92,19 @@ function useAction<
8392
84 const run = useCallback(93 const run = useCallback(
85 async (...args: TArgs) => {94 async (...args: TArgs) => {
95 if (
96 input.scope.environmentId === null ||
97 !readEnvironmentScope(input.scope.environmentId, AuthSourceControlWriteScope)
98 ) {
99 return AsyncResult.failure<never, EnvironmentAuthorizationError>(
100 Cause.fail(
101 new EnvironmentAuthorizationError({
102 requiredScope: AuthSourceControlWriteScope,
103 message: "This connection cannot change source control.",
104 }),
105 ),
106 );
107 }
86 const execute = async (): Promise<108 const execute = async (): Promise<
87 AtomCommandResult<AtomCommandSuccess<R>, AtomCommandFailure<R>>109 AtomCommandResult<AtomCommandSuccess<R>, AtomCommandFailure<R>>
88 > => {110 > => {
...@@ -108,6 +130,7 @@ function useAction<...@@ -108,6 +130,7 @@ function useAction<
108 );130 );
109131
110 return {132 return {
133 isAllowed,
111 error: ownsState ? state.error : null,134 error: ownsState ? state.error : null,
112 isPending: ownsState && state.isRunning,135 isPending: ownsState && state.isRunning,
113 resetError,136 resetError,
...@@ -307,6 +330,20 @@ export function usePreparePullRequestThreadAction(scope: SourceControlActionScop...@@ -307,6 +330,20 @@ export function usePreparePullRequestThreadAction(scope: SourceControlActionScop
307 ),330 ),
308 );331 );
309 }332 }
333 if (
334 input.mode === "worktree" &&
335 input.threadId !== undefined &&
336 !readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope)
337 ) {
338 return AsyncResult.failure<never, EnvironmentAuthorizationError>(
339 Cause.fail(
340 new EnvironmentAuthorizationError({
341 requiredScope: AuthOrchestrationOperateScope,
342 message: "This connection cannot change threads.",
343 }),
344 ),
345 );
346 }
310 return preparePullRequestThread({347 return preparePullRequestThread({
311 environmentId: target.environmentId,348 environmentId: target.environmentId,
312 input: {349 input: {
docs/user/remote-access.md+6
...@@ -281,6 +281,12 @@ Grouping checkouts does not combine their permissions. Shared project settings...@@ -281,6 +281,12 @@ Grouping checkouts does not combine their permissions. Shared project settings
281require `orchestration:operate` on every member environment; actions on one281require `orchestration:operate` on every member environment; actions on one
282checkout use that checkout's permissions.282checkout use that checkout's permissions.
283283
284`source-control:write` covers direct Git and pull request changes made from the
285client: pushing, switching or creating branches, cloning, and removing
286worktrees. It does not restrict what a task does. Starting a task in a new
287worktree still creates that branch and worktree with `orchestration:operate`,
288and the agent it runs can use Git however the environment allows.
289
284Settings changes, provider management, and environment maintenance can be granted290Settings changes, provider management, and environment maintenance can be granted
285separately from access administration. New standard pairings include these291separately from access administration. New standard pairings include these
286permissions. Existing clients keep their original grants after an update; to292permissions. Existing clients keep their original grants after an update; to
packages/client-runtime/src/rpc/client.ts+1-1
...@@ -203,7 +203,7 @@ export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* <...@@ -203,7 +203,7 @@ export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* <
203 return yield* method(input).pipe(Effect.ensuring(completeObservation));203 return yield* method(input).pipe(Effect.ensuring(completeObservation));
204});204});
205205
206function runStreamGuarded<TTag extends EnvironmentStreamCommandRpcTag>(206export function runStreamGuarded<TTag extends EnvironmentStreamCommandRpcTag>(
207 tag: TTag,207 tag: TTag,
208 input: EnvironmentRpcInput<TTag>,208 input: EnvironmentRpcInput<TTag>,
209): Stream.Stream<209): Stream.Stream<
packages/client-runtime/src/state/commandPermissions.test.ts+43
...@@ -2,6 +2,8 @@ import { describe, expect, it } from "@effect/vitest";...@@ -2,6 +2,8 @@ import { describe, expect, it } from "@effect/vitest";
2import { vi } from "vite-plus/test";2import { vi } from "vite-plus/test";
3import {3import {
4 AuthOrchestrationOperateScope,4 AuthOrchestrationOperateScope,
5 AuthSourceControlWriteScope,
6 ThreadId,
5 EnvironmentId,7 EnvironmentId,
6 ScheduledTaskId,8 ScheduledTaskId,
7 WS_METHODS,9 WS_METHODS,
...@@ -150,3 +152,44 @@ describe("command permissions", () => {...@@ -150,3 +152,44 @@ describe("command permissions", () => {
150 }152 }
151 });153 });
152});154});
155
156it.effect(
157 "requires source control alone for git, and both grants when attaching a worktree to a thread",
158 () =>
159 Effect.scoped(
160 Effect.gen(function* () {
161 const registry = yield* setup;
162 registry.set(sessions(env), AsyncResult.success(grant(true)));
163 const git = createCommandPermissions(runtime, WS_METHODS.vcsInit);
164 expect((yield* git.authorize(registry, env).pipe(Effect.flip)).requiredScope).toBe(
165 AuthSourceControlWriteScope,
166 );
167 registry.set(
168 sessions(env),
169 AsyncResult.success({ ...grant(false), scopes: [AuthSourceControlWriteScope] }),
170 );
171 yield* git.authorize(registry, env);
172 const prepare = createCommandPermissions(runtime, WS_METHODS.gitPreparePullRequestThread);
173 const input = {
174 cwd: "/repo",
175 reference: "123",
176 mode: "worktree",
177 threadId: ThreadId.make("thread"),
178 };
179 expect(registry.get(prepare.permissionAtom(env, input))).toBe(false);
180 expect(
181 (yield* prepare.authorize(registry, env, input).pipe(Effect.flip)).requiredScope,
182 ).toBe(AuthOrchestrationOperateScope);
183 yield* prepare.authorize(registry, env, { ...input, threadId: undefined });
184 registry.set(
185 sessions(env),
186 AsyncResult.success({
187 ...grant(true),
188 scopes: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope],
189 }),
190 );
191 expect(registry.get(prepare.permissionAtom(env, input))).toBe(true);
192 yield* prepare.authorize(registry, env, input);
193 }),
194 ),
195);
packages/client-runtime/src/state/pullRequests.test.ts+91
...@@ -1,9 +1,14 @@...@@ -1,9 +1,14 @@
1import { createCommandPermissions } from "./commandPermissions.ts";
2import { RpcPermissionGuard } from "../rpc/client.ts";
3import { vi } from "vite-plus/test";
1import {4import {
2 EnvironmentId,5 EnvironmentId,
6 AuthSourceControlWriteScope,
3 ProjectId,7 ProjectId,
4 PullRequestOperationError,8 PullRequestOperationError,
5 WS_METHODS,9 WS_METHODS,
6 type PullRequestStack,10 type PullRequestStack,
11 type AuthSessionState,
7} from "@t3tools/contracts";12} from "@t3tools/contracts";
8import { expect, it } from "@effect/vitest";13import { expect, it } from "@effect/vitest";
9import * as Data from "effect/Data";14import * as Data from "effect/Data";
...@@ -194,6 +199,7 @@ for (const scenario of [...@@ -194,6 +199,7 @@ for (const scenario of [
194 : route(WS_METHODS.pullRequestsRunAction, input)199 : route(WS_METHODS.pullRequestsRunAction, input)
195 ).pipe(200 ).pipe(
196 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),201 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
202 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
197 Effect.provideService(GitHubRoutingPermissions, {203 Effect.provideService(GitHubRoutingPermissions, {
198 ...trustedRouting,204 ...trustedRouting,
199 get: () => Effect.succeed(trusted ? "read-write" : "off"),205 get: () => Effect.succeed(trusted ? "read-write" : "off"),
...@@ -301,6 +307,7 @@ it.effect("shares active read identity probes while keeping later reads and muta...@@ -301,6 +307,7 @@ it.effect("shares active read identity probes while keeping later reads and muta
301 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),307 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
302 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),308 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
303 Effect.provideService(GitHubRoutingPermissions, trustedRouting),309 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
310 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
304 );311 );
305 }),312 }),
306 ),313 ),
...@@ -554,6 +561,7 @@ it.effect.each(["github", "gitlab", "bitbucket", "azure-devops"] as const)(...@@ -554,6 +561,7 @@ it.effect.each(["github", "gitlab", "bitbucket", "azure-devops"] as const)(
554 }).pipe(561 }).pipe(
555 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),562 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
556 Effect.provideService(GitHubRoutingPermissions, trustedRouting),563 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
564 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
557 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),565 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
558 );566 );
559 const environment = provider === "github" ? "local" : "origin";567 const environment = provider === "github" ? "local" : "origin";
...@@ -720,10 +728,12 @@ it.effect.each(["default", "origin-off", "destination-off", "read-only"] as cons...@@ -720,10 +728,12 @@ it.effect.each(["default", "origin-off", "destination-off", "read-only"] as cons
720 }).pipe(728 }).pipe(
721 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),729 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
722 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),730 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
731 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
723 );732 );
724 yield* permission === "default"733 yield* permission === "default"
725 ? request734 ? request
726 : request.pipe(735 : request.pipe(
736 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
727 Effect.provideService(GitHubRoutingPermissions, {737 Effect.provideService(GitHubRoutingPermissions, {
728 ...trustedRouting,738 ...trustedRouting,
729 get: (entry) =>739 get: (entry) =>
...@@ -842,6 +852,7 @@ it.effect.each(...@@ -842,6 +852,7 @@ it.effect.each(
842 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),852 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
843 // This also represents a user enabling the stale catalog entry after re-resolution.853 // This also represents a user enabling the stale catalog entry after re-resolution.
844 Effect.provideService(GitHubRoutingPermissions, trustedRouting),854 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
855 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
845 );856 );
846 yield* stored === "unavailable"857 yield* stored === "unavailable"
847 ? route858 ? route
...@@ -897,6 +908,7 @@ it.live.each(["origin", "alternate"] as const)(...@@ -897,6 +908,7 @@ it.live.each(["origin", "alternate"] as const)(
897 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),908 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
898 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),909 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
899 Effect.provideService(GitHubRoutingPermissions, trustedRouting),910 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
911 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
900 Effect.flip,912 Effect.flip,
901 );913 );
902 expect(error._tag).toBe("PullRequestOperationError");914 expect(error._tag).toBe("PullRequestOperationError");
...@@ -958,6 +970,7 @@ it.effect.each(...@@ -958,6 +970,7 @@ it.effect.each(
958 }).pipe(970 }).pipe(
959 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),971 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
960 Effect.provideService(GitHubRoutingPermissions, trustedRouting),972 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
973 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
961 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),974 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
962 );975 );
963 const fiber = yield* request.pipe(Effect.forkChild);976 const fiber = yield* request.pipe(Effect.forkChild);
...@@ -1014,6 +1027,7 @@ it.live("keeps source workspace metadata when an alternate answers a detail read...@@ -1014,6 +1027,7 @@ it.live("keeps source workspace metadata when an alternate answers a detail read
1014 }).pipe(1027 }).pipe(
1015 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),1028 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
1016 Effect.provideService(GitHubRoutingPermissions, trustedRouting),1029 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
1030 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
1017 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),1031 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
1018 );1032 );
1019 expect(result).toEqual({1033 expect(result).toEqual({
...@@ -1101,6 +1115,7 @@ it.live(...@@ -1101,6 +1115,7 @@ it.live(
1101 }).pipe(1115 }).pipe(
1102 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),1116 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
1103 Effect.provideService(GitHubRoutingPermissions, trustedRouting),1117 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
1118 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
1104 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),1119 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
1105 );1120 );
1106 }),1121 }),
...@@ -1649,6 +1664,7 @@ it.effect.each([false, true])(...@@ -1649,6 +1664,7 @@ it.effect.each([false, true])(
1649 }).pipe(1664 }).pipe(
1650 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),1665 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
1651 Effect.provideService(GitHubRoutingPermissions, trustedRouting),1666 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
1667 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
1652 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),1668 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
1653 );1669 );
1654 expect(result).toEqual({ state: "open", checks: [] });1670 expect(result).toEqual({ state: "open", checks: [] });
...@@ -1980,3 +1996,78 @@ it.effect("refreshes stack state after reopening and head SHAs after a turn", ()...@@ -1980,3 +1996,78 @@ it.effect("refreshes stack state after reopening and head SHAs after a turn", ()
1980 }),1996 }),
1981 ),1997 ),
1982);1998);
1999
2000// Transport fixtures have a source-control-only session; authorization edge cases
2001// are exercised by commandPermissions.test.ts.
2002vi.mock("./session.ts", () => ({
2003 createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }),
2004}));
2005const grantedSessions = Atom.family((_id: EnvironmentId) =>
2006 Atom.make<AsyncResult.AsyncResult<AuthSessionState>>(
2007 AsyncResult.success({
2008 authenticated: true,
2009 auth: {
2010 policy: "remote-reachable" as const,
2011 bootstrapMethods: [],
2012 sessionMethods: [],
2013 sessionCookieName: "test",
2014 },
2015 scopes: [AuthSourceControlWriteScope],
2016 }),
2017 ),
2018);
2019
2020it.effect("denies a routed write when only the origin has source-control permission", () =>
2021 Effect.scoped(
2022 Effect.gen(function* () {
2023 let writes = 0;
2024 const identity = { host: "github.com", provider: "github", viewer: "test", accountId: "123" };
2025 const client = {
2026 [WS_METHODS.pullRequestsRouting]: () => Effect.succeed(identity),
2027 [WS_METHODS.pullRequestsRoutingIdentity]: () => Effect.succeed(identity),
2028 [WS_METHODS.pullRequestsRunAction]: () =>
2029 Effect.sync(() => {
2030 writes++;
2031 }),
2032 } as unknown as WsRpcProtocolClient;
2033 const fixture = yield* makeTestRuntime(client, client);
2034 const local = EnvironmentId.make("local-environment");
2035 const stop = fixture.registry.mount(grantedSessions(local));
2036 yield* Effect.addFinalizer(() => Effect.sync(stop));
2037 fixture.registry.set(
2038 grantedSessions(local),
2039 AsyncResult.success({
2040 authenticated: true,
2041 scopes: [],
2042 auth: {
2043 policy: "remote-reachable" as const,
2044 bootstrapMethods: [],
2045 sessionMethods: [],
2046 sessionCookieName: "test",
2047 },
2048 }),
2049 );
2050 const error = yield* createPullRequestRouter()(WS_METHODS.pullRequestsRunAction, {
2051 projectId: ProjectId.make("project"),
2052 repository: "acme/repo",
2053 number: 1,
2054 action: "merge",
2055 }).pipe(
2056 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, fixture.environmentRegistry),
2057 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, fixture.supervisor),
2058 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
2059 Effect.provideService(RpcPermissionGuard, {
2060 authorize: (id, method, input) =>
2061 createCommandPermissions(fixture.runtime, method).authorize(
2062 fixture.registry,
2063 id,
2064 input,
2065 ),
2066 }),
2067 Effect.flip,
2068 );
2069 expect(error._tag).toBe("EnvironmentAuthorizationError");
2070 expect(writes).toBe(0);
2071 }),
2072 ),
2073);
packages/client-runtime/src/state/pullRequests.ts+5-6
...@@ -19,7 +19,6 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";...@@ -19,7 +19,6 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
1919
20import {20import {
21 createAtomCommandScheduler,21 createAtomCommandScheduler,
22 createEnvironmentCommand,
23 createEnvironmentRpcCommand,22 createEnvironmentRpcCommand,
24 createEnvironmentRpcQueryAtomFamily,23 createEnvironmentRpcQueryAtomFamily,
25 createEnvironmentRpcSubscriptionAtomFamily,24 createEnvironmentRpcSubscriptionAtomFamily,
...@@ -320,7 +319,8 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -320,7 +319,8 @@ export function createPullRequestEnvironmentAtoms<R, E>(
320 JSON.stringify([environmentId, input.projectId, input.repository, input.number]),319 JSON.stringify([environmentId, input.projectId, input.repository, input.number]),
321 },320 },
322 }),321 }),
323 runAction: createEnvironmentCommand(runtime, {322 runAction: createEnvironmentRpcCommand(runtime, {
323 tag: WS_METHODS.pullRequestsRunAction,
324 label: "environment-data:pull-requests:run-action",324 label: "environment-data:pull-requests:run-action",
325 // Preparation belongs to the write's lane. Refreshable queries would restart it after325 // Preparation belongs to the write's lane. Refreshable queries would restart it after
326 // every preceding action, and preparing outside the lane could reorder the clicks.326 // every preceding action, and preparing outside the lane could reorder the clicks.
...@@ -328,8 +328,6 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -328,8 +328,6 @@ export function createPullRequestEnvironmentAtoms<R, E>(
328 input: PullRequestActionInput & {328 input: PullRequestActionInput & {
329 readonly resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod;329 readonly resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod;
330 },330 },
331 registry,
332 environmentId,
333 ) =>331 ) =>
334 Effect.gen(function* () {332 Effect.gen(function* () {
335 const { resolveMergeMethod, ...actionInput } = input;333 const { resolveMergeMethod, ...actionInput } = input;
...@@ -369,9 +367,10 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -369,9 +367,10 @@ export function createPullRequestEnvironmentAtoms<R, E>(
369 });367 });
370 preparedInput = { ...actionInput, mergeMethod };368 preparedInput = { ...actionInput, mergeMethod };
371 }369 }
372 yield* routedRequest(WS_METHODS.pullRequestsRunAction, preparedInput);370 return yield* routedRequest(WS_METHODS.pullRequestsRunAction, preparedInput);
373 registry.refresh(preview({ environmentId, input: actionInput }));
374 }),371 }),
372 onSuccess: ({ environmentId, input }, registry) =>
373 Effect.sync(() => registry.refresh(preview({ environmentId, input }))),
375 scheduler: commandScheduler,374 scheduler: commandScheduler,
376 concurrency: serialPerEnvironment,375 concurrency: serialPerEnvironment,
377 }),376 }),
packages/client-runtime/src/state/runtime.ts+12-7
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { createCommandPermissions } from "./commandPermissions.ts";1import { createCommandPermissions } from "./commandPermissions.ts";
2import { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts";2import { followStreamInEnvironment } from "./environmentStreams.ts";
3export { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts";3export { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts";
4import {4import {
5 type ClientGuardedRpcTag,5 type ClientGuardedRpcTag,
...@@ -717,13 +717,18 @@ export function createEnvironmentRpcSubscriptionAtomFamily<...@@ -717,13 +717,18 @@ export function createEnvironmentRpcSubscriptionAtomFamily<
717 });717 });
718}718}
719719
720export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnaryRpcTag>(720export function createEnvironmentRpcCommand<
721 R,
722 ER,
723 TTag extends EnvironmentUnaryRpcTag,
724 Input extends EnvironmentRpcInput<TTag> = EnvironmentRpcInput<TTag>,
725>(
721 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, ER>,726 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, ER>,
722 options: {727 options: {
723 readonly label: string;728 readonly label: string;
724 readonly tag: TTag;729 readonly tag: TTag;
725 readonly execute?: (730 readonly execute?: (
726 input: EnvironmentRpcInput<TTag>,731 input: Input,
727 ) => Effect.Effect<732 ) => Effect.Effect<
728 EnvironmentRpcSuccess<TTag>,733 EnvironmentRpcSuccess<TTag>,
729 EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError,734 EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError,
...@@ -732,19 +737,19 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary...@@ -732,19 +737,19 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary
732 readonly scheduler?: AtomCommandScheduler;737 readonly scheduler?: AtomCommandScheduler;
733 readonly concurrency?: AtomCommandConcurrency<{738 readonly concurrency?: AtomCommandConcurrency<{
734 readonly environmentId: EnvironmentIdType;739 readonly environmentId: EnvironmentIdType;
735 readonly input: EnvironmentRpcInput<TTag>;740 readonly input: NoInfer<Input>;
736 }>;741 }>;
737 readonly onSuccess?: (742 readonly onSuccess?: (
738 target: {743 target: {
739 readonly environmentId: EnvironmentIdType;744 readonly environmentId: EnvironmentIdType;
740 readonly input: EnvironmentRpcInput<TTag>;745 readonly input: NoInfer<Input>;
741 },746 },
742 registry: AtomRegistry.AtomRegistry,747 registry: AtomRegistry.AtomRegistry,
743 ) => Effect.Effect<void, never, R>;748 ) => Effect.Effect<void, never, R>;
744 readonly onSettled?: (749 readonly onSettled?: (
745 target: {750 target: {
746 readonly environmentId: EnvironmentIdType;751 readonly environmentId: EnvironmentIdType;
747 readonly input: EnvironmentRpcInput<TTag>;752 readonly input: NoInfer<Input>;
748 },753 },
749 registry: AtomRegistry.AtomRegistry,754 registry: AtomRegistry.AtomRegistry,
750 ) => Effect.Effect<void, never, R>;755 ) => Effect.Effect<void, never, R>;
...@@ -755,7 +760,7 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary...@@ -755,7 +760,7 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary
755 label: options.label,760 label: options.label,
756 ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }),761 ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }),
757 ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }),762 ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }),
758 execute: (input: EnvironmentRpcInput<TTag>, registry, environmentId) => {763 execute: (input: Input, registry, environmentId) => {
759 const target = {764 const target = {
760 environmentId,765 environmentId,
761 input,766 input,
packages/client-runtime/src/state/sourceControl.test.ts+22
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import { vi } from "vite-plus/test";
1import {2import {
2 EnvironmentId,3 EnvironmentId,
4 AuthSourceControlWriteScope,
3 WS_METHODS,5 WS_METHODS,
4 type SourceControlPublishRepositoryResult,6 type SourceControlPublishRepositoryResult,
5} from "@t3tools/contracts";7} from "@t3tools/contracts";
...@@ -168,3 +170,23 @@ describe("source control environment atoms", () => {...@@ -168,3 +170,23 @@ describe("source control environment atoms", () => {
168 ),170 ),
169 );171 );
170});172});
173
174// Transport fixtures have a source-control-only session; authorization edge cases
175// are exercised by commandPermissions.test.ts.
176vi.mock("./session.ts", () => ({
177 createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }),
178}));
179const grantedSessions = Atom.family((_id: EnvironmentId) =>
180 Atom.make(
181 AsyncResult.success({
182 authenticated: true,
183 auth: {
184 policy: "remote-reachable",
185 bootstrapMethods: [],
186 sessionMethods: [],
187 sessionCookieName: "test",
188 },
189 scopes: [AuthSourceControlWriteScope],
190 }),
191 ),
192);
packages/client-runtime/src/state/vcs.test.ts+22
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import { vi } from "vite-plus/test";
1import {2import {
2 EnvironmentId,3 EnvironmentId,
4 AuthSourceControlWriteScope,
3 WS_METHODS,5 WS_METHODS,
4 type VcsListRefsInput,6 type VcsListRefsInput,
5 type VcsListRefsResult,7 type VcsListRefsResult,
...@@ -643,3 +645,23 @@ describe("cached VCS refs", () => {...@@ -643,3 +645,23 @@ describe("cached VCS refs", () => {
643 ),645 ),
644 );646 );
645});647});
648
649// Transport fixtures have a source-control-only session; authorization edge cases
650// are exercised by commandPermissions.test.ts.
651vi.mock("./session.ts", () => ({
652 createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }),
653}));
654const grantedSessions = Atom.family((_id: EnvironmentId) =>
655 Atom.make(
656 AsyncResult.success({
657 authenticated: true,
658 auth: {
659 policy: "remote-reachable",
660 bootstrapMethods: [],
661 sessionMethods: [],
662 sessionCookieName: "test",
663 },
664 scopes: [AuthSourceControlWriteScope],
665 }),
666 ),
667);
packages/client-runtime/src/state/vcsAction.test.ts+22
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import { vi } from "vite-plus/test";
1import {2import {
2 EnvironmentId,3 EnvironmentId,
4 AuthSourceControlWriteScope,
3 ThreadId,5 ThreadId,
4 ProjectId,6 ProjectId,
5 WS_METHODS,7 WS_METHODS,
...@@ -718,3 +720,23 @@ describe("vcsActionState", () => {...@@ -718,3 +720,23 @@ describe("vcsActionState", () => {
718 ),720 ),
719 );721 );
720});722});
723
724// Transport fixtures have a source-control-only session; authorization edge cases
725// are exercised by commandPermissions.test.ts.
726vi.mock("./session.ts", () => ({
727 createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }),
728}));
729const grantedSessions = Atom.family((_id: EnvironmentId) =>
730 Atom.make(
731 AsyncResult.success({
732 authenticated: true,
733 auth: {
734 policy: "remote-reachable",
735 bootstrapMethods: [],
736 sessionMethods: [],
737 sessionCookieName: "test",
738 },
739 scopes: [AuthSourceControlWriteScope],
740 }),
741 ),
742);
packages/client-runtime/src/state/vcsAction.ts+7-2
...@@ -19,13 +19,14 @@ import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity";...@@ -19,13 +19,14 @@ import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity";
1919
20import type { EnvironmentRegistry } from "../connection/registry.ts";20import type { EnvironmentRegistry } from "../connection/registry.ts";
21import * as Persistence from "../platform/persistence.ts";21import * as Persistence from "../platform/persistence.ts";
22import { runStream } from "../rpc/client.ts";22import { runStreamGuarded, RpcPermissionGuard } from "../rpc/client.ts";
23import {23import {
24 createRuntimeCommand,24 createRuntimeCommand,
25 runStreamInEnvironment,25 runStreamInEnvironment,
26 type AtomCommand,26 type AtomCommand,
27 type AtomCommandResult,27 type AtomCommandResult,
28} from "./runtime.ts";28} from "./runtime.ts";
29import { createCommandPermissions } from "./commandPermissions.ts";
29import { vcsCommandScheduler } from "./vcsCommandScheduler.ts";30import { vcsCommandScheduler } from "./vcsCommandScheduler.ts";
30import { invalidateCachedVcsRefs } from "./vcsRefInvalidation.ts";31import { invalidateCachedVcsRefs } from "./vcsRefInvalidation.ts";
3132
...@@ -483,7 +484,7 @@ export function createVcsActionManager<R, E>(...@@ -483,7 +484,7 @@ export function createVcsActionManager<R, E>(
483 return consumeVcsActionProgress(484 return consumeVcsActionProgress(
484 runStreamInEnvironment(485 runStreamInEnvironment(
485 target.environmentId,486 target.environmentId,
486 runStream(WS_METHODS.gitRunStackedAction, rpcInput),487 runStreamGuarded(WS_METHODS.gitRunStackedAction, rpcInput),
487 ),488 ),
488 {489 {
489 target,490 target,
...@@ -507,6 +508,10 @@ export function createVcsActionManager<R, E>(...@@ -507,6 +508,10 @@ export function createVcsActionManager<R, E>(
507 }),508 }),
508 },509 },
509 ).pipe(510 ).pipe(
511 Effect.provideService(RpcPermissionGuard, {
512 authorize: (id, method, payload) =>
513 createCommandPermissions(runtime, method).authorize(registry, id, payload),
514 }),
510 Effect.ensuring(invalidateCachedVcsRefs(registry, target)),515 Effect.ensuring(invalidateCachedVcsRefs(registry, target)),
511 Effect.tap(() => clearOwnedState),516 Effect.tap(() => clearOwnedState),
512 Effect.tapError((error) =>517 Effect.tapError((error) =>
packages/contracts/src/auth.ts+3
...@@ -84,6 +84,7 @@ export const AuthSettingsWriteScope = "settings:write" as const;...@@ -84,6 +84,7 @@ export const AuthSettingsWriteScope = "settings:write" as const;
84export const AuthProvidersManageScope = "providers:manage" as const;84export const AuthProvidersManageScope = "providers:manage" as const;
85export const AuthEnvironmentMaintainScope = "environment:maintain" as const;85export const AuthEnvironmentMaintainScope = "environment:maintain" as const;
86export const AuthTerminalOperateScope = "terminal:operate" as const;86export const AuthTerminalOperateScope = "terminal:operate" as const;
87export const AuthSourceControlWriteScope = "source-control:write" as const;
87export const AuthReviewWriteScope = "review:write" as const;88export const AuthReviewWriteScope = "review:write" as const;
88export const AuthAccessReadScope = "access:read" as const;89export const AuthAccessReadScope = "access:read" as const;
89export const AuthAccessWriteScope = "access:write" as const;90export const AuthAccessWriteScope = "access:write" as const;
...@@ -97,6 +98,7 @@ export const AuthEnvironmentScope = Schema.Literals([...@@ -97,6 +98,7 @@ export const AuthEnvironmentScope = Schema.Literals([
97 AuthEnvironmentMaintainScope,98 AuthEnvironmentMaintainScope,
98 AuthTerminalOperateScope,99 AuthTerminalOperateScope,
99 AuthReviewWriteScope,100 AuthReviewWriteScope,
101 AuthSourceControlWriteScope,
100 AuthAccessReadScope,102 AuthAccessReadScope,
101 AuthAccessWriteScope,103 AuthAccessWriteScope,
102 AuthRelayReadScope,104 AuthRelayReadScope,
...@@ -114,6 +116,7 @@ export const AuthStandardClientScopes = [...@@ -114,6 +116,7 @@ export const AuthStandardClientScopes = [
114 AuthEnvironmentMaintainScope,116 AuthEnvironmentMaintainScope,
115 AuthTerminalOperateScope,117 AuthTerminalOperateScope,
116 AuthReviewWriteScope,118 AuthReviewWriteScope,
119 AuthSourceControlWriteScope,
117 AuthRelayReadScope,120 AuthRelayReadScope,
118] as const;121] as const;
119export const AuthAdministrativeScopes = [122export const AuthAdministrativeScopes = [
packages/contracts/src/clientRpcPermissions.ts+40-2
...@@ -1,8 +1,39 @@...@@ -1,8 +1,39 @@
1import { AuthOrchestrationOperateScope, type AuthEnvironmentScope } from "./auth.ts";1import * as Schema from "effect/Schema";
2import { GitPreparePullRequestThreadInput } from "./git.ts";
3import {
4 AuthOrchestrationOperateScope,
5 AuthSourceControlWriteScope,
6 type AuthEnvironmentScope,
7} from "./auth.ts";
2import { WS_METHODS } from "./rpc.ts";8import { WS_METHODS } from "./rpc.ts";
39
4/** Incremental client enforcement; the server still authorizes every request. */10/** Incremental client enforcement; the server still authorizes every request. */
5export const CLIENT_GUARDED_RPC_SCOPES = {11export const CLIENT_GUARDED_RPC_SCOPES = {
12 [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope,
13 [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope,
14 [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope,
15 [WS_METHODS.pullRequestsUpdateComment]: AuthSourceControlWriteScope,
16 [WS_METHODS.pullRequestsSubmitReview]: AuthSourceControlWriteScope,
17 [WS_METHODS.pullRequestsReplyToThread]: AuthSourceControlWriteScope,
18 [WS_METHODS.pullRequestsSetThreadResolution]: AuthSourceControlWriteScope,
19 [WS_METHODS.pullRequestsSetReaction]: AuthSourceControlWriteScope,
20 [WS_METHODS.pullRequestsSetFilesViewed]: AuthSourceControlWriteScope,
21 [WS_METHODS.pullRequestsRequestReviewers]: AuthSourceControlWriteScope,
22 [WS_METHODS.pullRequestsSetLabels]: AuthSourceControlWriteScope,
23 [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope,
24 [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope,
25 [WS_METHODS.projectCloneStart]: AuthSourceControlWriteScope,
26 [WS_METHODS.projectCloneCancel]: AuthSourceControlWriteScope,
27 [WS_METHODS.projectCloneRetry]: AuthSourceControlWriteScope,
28 [WS_METHODS.vcsPull]: AuthSourceControlWriteScope,
29 [WS_METHODS.gitRunStackedAction]: AuthSourceControlWriteScope,
30 [WS_METHODS.gitPreparePullRequestThread]: AuthSourceControlWriteScope,
31 [WS_METHODS.vcsCreateWorktree]: AuthSourceControlWriteScope,
32 [WS_METHODS.vcsRemoveWorktree]: AuthSourceControlWriteScope,
33 [WS_METHODS.vcsCreateRef]: AuthSourceControlWriteScope,
34 [WS_METHODS.vcsSwitchRef]: AuthSourceControlWriteScope,
35 [WS_METHODS.vcsInit]: AuthSourceControlWriteScope,
36
6 [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope,37 [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope,
7 [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope,38 [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope,
8 [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope,39 [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope,
...@@ -11,10 +42,17 @@ export const CLIENT_GUARDED_RPC_SCOPES = {...@@ -11,10 +42,17 @@ export const CLIENT_GUARDED_RPC_SCOPES = {
11} as const;42} as const;
12export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES;43export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES;
1344
45const decodePrepareThread = Schema.decodeUnknownSync(GitPreparePullRequestThreadInput);
46
14export function clientRpcRequiredScopes(47export function clientRpcRequiredScopes(
15 method: string,48 method: string,
16 _input: unknown,49 input: unknown,
17): readonly AuthEnvironmentScope[] {50): readonly AuthEnvironmentScope[] {
51 if (method === WS_METHODS.gitPreparePullRequestThread && input !== undefined) {
52 const payload = decodePrepareThread(input);
53 if (payload.mode === "worktree" && payload.threadId !== undefined)
54 return [AuthSourceControlWriteScope, AuthOrchestrationOperateScope];
55 }
18 return Object.hasOwn(CLIENT_GUARDED_RPC_SCOPES, method)56 return Object.hasOwn(CLIENT_GUARDED_RPC_SCOPES, method)
19 ? [CLIENT_GUARDED_RPC_SCOPES[method as ClientGuardedRpcTag]]57 ? [CLIENT_GUARDED_RPC_SCOPES[method as ClientGuardedRpcTag]]
20 : [];58 : [];