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 {
3939 isWindowsPlatform,
4040} from "@t3tools/client-runtime/state/projects";
4141import {
42 AuthOrchestrationOperateScope,
43 AuthSourceControlWriteScope,
4244 CommandId,
4345 type EnvironmentId,
4446 type EnvironmentMachineKind,
......@@ -59,6 +61,7 @@ import { useProjects, useServerConfigs, waitForProject } from "../../state/entit
5961import { filesystemEnvironment } from "../../state/filesystem";
6062import { projectEnvironment } from "../../state/projects";
6163import { useEnvironmentQuery } from "../../state/query";
64import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
6265import { sourceControlEnvironment } from "../../state/sourceControl";
6366import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
6467import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
......@@ -522,6 +525,15 @@ export function AddProjectSourceScreen() {
522525 const navigation = useNavigation();
523526 const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } =
524527 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;
525537 const discoveryState = useEnvironmentQuery(
526538 selectedEnvironment === null
527539 ? null
......@@ -610,7 +622,11 @@ export function AddProjectSourceScreen() {
610622 ) : null}
611623 <ListRow
612624 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 }
614630 icon={
615631 <SymbolView
616632 name="folder.badge.plus"
......@@ -620,6 +636,7 @@ export function AddProjectSourceScreen() {
620636 />
621637 }
622638 isFirst={selectedEnvironment.newProjectsRoot === null}
639 disabled={!canCreateProject}
623640 onPress={() =>
624641 navigation.dispatch(
625642 StackActions.push("AddProjectLocal", {
......@@ -634,11 +651,13 @@ export function AddProjectSourceScreen() {
634651 key={candidate}
635652 source={candidate}
636653 selectedEnvironmentId={selectedEnvironment.environmentId}
637 ready={readiness[candidate].ready}
654 ready={canCloneProject && readiness[candidate].ready}
638655 hint={
639 readiness[candidate].ready
640 ? addProjectRemoteSourcePathHint(candidate)
641 : (readiness[candidate].hint ?? "")
656 !canCloneProject
657 ? "This connection cannot clone projects."
658 : readiness[candidate].ready
659 ? addProjectRemoteSourcePathHint(candidate)
660 : (readiness[candidate].hint ?? "")
642661 }
643662 isFirst={false}
644663 />
......@@ -670,7 +689,17 @@ function useCreateProject(environment: EnvironmentOption | null) {
670689
671690 return useCallback(
672691 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
675704 const existing = findExistingAddProject({
676705 projects,
......@@ -1159,6 +1188,10 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
11591188
11601189export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) {
11611190 const environment = useEnvironmentFromParam(props.environmentId);
1191 const canCreateProject = useEnvironmentScope(
1192 environment?.environmentId ?? null,
1193 AuthOrchestrationOperateScope,
1194 );
11621195 const createProject = useCreateProject(environment);
11631196 const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } =
11641197 useBrowsePathInput(environment);
......@@ -1191,15 +1224,14 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st
11911224 {error ? <ErrorBanner message={error} /> : null}
11921225 {environment ? (
11931226 <>
1194 <ProjectPathInput
1195 value={pathInput}
1196 onChangeText={setPathInput}
1197 onSubmit={() => void submitPath()}
1198 />
1227 {!canCreateProject ? (
1228 <ErrorBanner message="This connection cannot add projects." />
1229 ) : null}
1230 <ProjectPathInput value={pathInput} onChangeText={setPathInput} onSubmit={submitPath} />
11991231 <PrimaryActionButton
12001232 label="Add project"
1201 disabled={isBrowseNavigating || isSubmitting}
1202 onPress={() => void submitPath()}
1233 disabled={!canCreateProject || isBrowseNavigating || isSubmitting}
1234 onPress={submitPath}
12031235 loading={isSubmitting}
12041236 />
12051237 <FolderBrowser
......@@ -1230,6 +1262,15 @@ export function AddProjectDestinationScreen(props: {
12301262 });
12311263 const navigation = useNavigation();
12321264 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;
12331274 const createProject = useCreateProject(environment);
12341275 const remoteUrl = stringParam(props.remoteUrl);
12351276 const repositoryTitle = stringParam(props.repositoryTitle);
......@@ -1246,7 +1287,16 @@ export function AddProjectDestinationScreen(props: {
12461287 const [error, setError] = useState<string | null>(null);
12471288
12481289 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 }
12501300 setError(null);
12511301 const resolved = resolveAddProjectPath({
12521302 rawPath: pathInput,
......@@ -1342,17 +1392,18 @@ export function AddProjectDestinationScreen(props: {
13421392 ) : null}
13431393 {environment ? (
13441394 <>
1345 <ProjectPathInput
1346 value={pathInput}
1347 onChangeText={setPathInput}
1348 onSubmit={() => void submitPath()}
1349 />
1395 <ProjectPathInput value={pathInput} onChangeText={setPathInput} onSubmit={submitPath} />
13501396 <PrimaryActionButton
13511397 label="Clone project"
1352 disabled={isBrowseNavigating || isSubmitting || !remoteUrl}
1353 onPress={() => void submitPath()}
1398 disabled={!canCloneProject || isBrowseNavigating || isSubmitting || !remoteUrl}
1399 onPress={submitPath}
13541400 loading={isSubmitting}
13551401 />
1402 {!canCloneProject ? (
1403 <Text className="text-sm text-foreground-muted">
1404 This connection cannot clone projects.
1405 </Text>
1406 ) : null}
13561407 <FolderBrowser
13571408 environment={environment}
13581409 navigateToBrowsePath={navigateToBrowsePath}
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+14-2
......@@ -1,7 +1,8 @@
11import { MaterialListRow } from "../../components/MaterialListRow";
22import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
3import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation";
34import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
4import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
5import { AuthSourceControlWriteScope, resolveEnvironmentMachineKind } from "@t3tools/contracts";
56import { LegendList } from "@legendapp/list/react-native";
67import {
78 isAtomCommandInterrupted,
......@@ -30,6 +31,7 @@ import { ThemedSwitch } from "../../components/ThemedSwitch";
3031import { cn } from "../../lib/cn";
3132import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
3233import { useServerConfigs } from "../../state/entities";
34import { useEnvironmentScope } from "../../state/session";
3335import { useAtomCommand } from "../../state/use-atom-command";
3436import { vcsEnvironment } from "../../state/vcs";
3537import {
......@@ -264,6 +266,10 @@ export function NewTaskEnvironmentPickerRouteScreen() {
264266
265267export function NewTaskBranchPickerRouteScreen() {
266268 const flow = useNewTaskFlow();
269 const canWriteSourceControl = useEnvironmentScope(
270 flow.selectedProject?.environmentId ?? null,
271 AuthSourceControlWriteScope,
272 );
267273 const navigation = useNavigation();
268274 const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
269275 const [switchingBranchName, setSwitchingBranchName] = useState<string | null>(null);
......@@ -291,7 +297,12 @@ export function NewTaskBranchPickerRouteScreen() {
291297
292298 const selectBranch = useCallback(
293299 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)) {
295306 return;
296307 }
297308 selectingBranchNameRef.current = branch.name;
......@@ -336,6 +347,7 @@ export function NewTaskBranchPickerRouteScreen() {
336347 }
337348 },
338349 [
350 canWriteSourceControl,
339351 flow.selectBranch,
340352 flow.selectedProject,
341353 flow.setBranchQuery,
apps/mobile/src/features/threads/ThreadGitControls.tsx+35-13
......@@ -1,6 +1,7 @@
11import { createNativeHeaderMenu } from "../../components/nativeHeaderMenu.ios";
22import type { ScreenHeaderMenu } from "../../components/ScreenHeader.types";
33import {
4 AuthSourceControlWriteScope,
45 EnvironmentId,
56 type GitRunStackedActionResult,
67 type ProjectScript,
......@@ -17,6 +18,7 @@ import { NativeHeaderToolbar } from "../../native/StackHeader";
1718import { useCallback, useMemo } from "react";
1819import { Alert } from "react-native";
1920import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
21import { useEnvironmentScope } from "../../state/session";
2022import {
2123 basename,
2224 getTerminalStatusLabel,
......@@ -113,6 +115,10 @@ type ThreadGitControlsProps = ThreadGitMenuProps & {
113115function useThreadGitControlModel(props: ThreadGitMenuProps) {
114116 const navigation = useNavigation();
115117 const environmentId = props.environmentId;
118 const canWriteSourceControl = useEnvironmentScope(
119 environmentId ? EnvironmentId.make(String(environmentId)) : null,
120 AuthSourceControlWriteScope,
121 );
116122 const threadId = props.threadId;
117123 const { gitStatus, gitOperationLabel, onPull, onRunAction } = props;
118124
......@@ -122,18 +128,24 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) {
122128 const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;
123129 const isDefaultRef = gitStatus?.isDefaultRef ?? false;
124130
125 const quickAction = useMemo(
126 () =>
127 isRepo
128 ? resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote)
129 : {
130 label: "Git unavailable",
131 disabled: true,
132 kind: "show_hint" as const,
133 hint: "This workspace is not a git repository.",
134 },
135 [busy, gitStatus, hasPrimaryRemote, isDefaultRef, isRepo],
136 );
131 const quickAction = useMemo(() => {
132 if (!isRepo) {
133 return {
134 label: "Git unavailable",
135 disabled: true,
136 kind: "show_hint" as const,
137 hint: "This workspace is not a git repository.",
138 };
139 }
140 const action = resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote);
141 return !canWriteSourceControl && (action.kind === "run_pull" || action.kind === "run_action")
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
138150 const quickActionHint = quickAction.disabled
139151 ? (quickAction.hint ?? "This action is unavailable.")
......@@ -163,6 +175,7 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) {
163175
164176 const runActionWithPrompt = useCallback(
165177 async (input: GitActionRequestInput) => {
178 if (!canWriteSourceControl) return;
166179 const confirmableAction =
167180 input.action === "push" ||
168181 input.action === "create_pr" ||
......@@ -191,10 +204,19 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) {
191204
192205 await onRunAction(input);
193206 },
194 [environmentId, gitStatus, isDefaultRef, onRunAction, navigation, threadId],
207 [
208 canWriteSourceControl,
209 environmentId,
210 gitStatus,
211 isDefaultRef,
212 onRunAction,
213 navigation,
214 threadId,
215 ],
195216 );
196217
197218 const runQuickAction = useCallback(async () => {
219 if (quickAction.disabled) return;
198220 if (quickAction.kind === "open_pr") {
199221 await openExistingPr();
200222 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) {
3030 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
3131 const gitState = useSelectedThreadGitState();
3232 const gitActions = useSelectedThreadGitActions();
33 const { canChangeThreadBranch } = gitActions;
3334
3435 const gitStatus = useEnvironmentQuery(
3536 selectedThread !== null && selectedThreadCwd !== null
......@@ -109,11 +110,12 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {
109110 icon="plus"
110111 label="Create & checkout"
111112 tone="primary"
112 disabled={busy || newBranchName.trim().length === 0}
113 disabled={!canChangeThreadBranch || busy || newBranchName.trim().length === 0}
113114 onPress={() => {
114115 const branch = sanitizeFeatureBranchName(newBranchName.trim());
115116 if (branch.length === 0) return;
116 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {
117 void gitActions.onCreateSelectedThreadBranch(branch).then((result) => {
118 if (result === null) return;
117119 setNewBranchName("");
118120 navigation.goBack();
119121 });
......@@ -150,6 +152,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {
150152 label="Create worktree"
151153 tone="primary"
152154 disabled={
155 !canChangeThreadBranch ||
153156 busy ||
154157 worktreeBaseBranch.trim().length === 0 ||
155158 worktreeBranchName.trim().length === 0
......@@ -160,7 +163,8 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {
160163 if (baseBranch.length === 0 || newBranch.length === 0) return;
161164 void gitActions
162165 .onCreateSelectedThreadWorktree({ baseBranch, newBranch })
163 .then(() => {
166 .then((result) => {
167 if (result === null) return;
164168 setWorktreeBranchName("");
165169 navigation.goBack();
166170 });
......@@ -203,9 +207,10 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {
203207 )}
204208 accessibilityRole="button"
205209 accessibilityState={{ selected: branch.current, disabled: busy || disabled }}
206 disabled={busy || disabled}
210 disabled={!canChangeThreadBranch || busy || disabled}
207211 onPress={() => {
208 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {
212 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then((result) => {
213 if (result === null) return;
209214 navigation.goBack();
210215 });
211216 }}
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+13-3
......@@ -30,6 +30,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
3030 const { selectedThreadCwd } = useSelectedThreadWorktree();
3131 const gitState = useSelectedThreadGitState();
3232 const gitActions = useSelectedThreadGitActions();
33 const { canWriteSourceControl, canChangeThreadBranch } = gitActions;
3334
3435 const gitStatus = useEnvironmentQuery(
3536 selectedThread !== null && selectedThreadCwd !== null
......@@ -57,6 +58,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
5758
5859 const runCommitAction = useCallback(
5960 async (featureBranch: boolean) => {
61 if (!canWriteSourceControl || (featureBranch && !canChangeThreadBranch)) return;
6062 const commitMessage = dialogCommitMessage.trim();
6163 navigation.goBack();
6264 await gitActions.onRunSelectedThreadGitAction({
......@@ -66,7 +68,15 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
6668 ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),
6769 });
6870 },
69 [allSelected, dialogCommitMessage, gitActions, navigation, selectedFiles],
71 [
72 allSelected,
73 canWriteSourceControl,
74 canChangeThreadBranch,
75 dialogCommitMessage,
76 gitActions,
77 navigation,
78 selectedFiles,
79 ],
7080 );
7181
7282 return (
......@@ -274,7 +284,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
274284 <SheetActionButton
275285 icon="arrow.branch"
276286 label="Commit on new branch"
277 disabled={noneSelected || busy}
287 disabled={!canChangeThreadBranch || noneSelected || busy}
278288 onPress={() => void runCommitAction(true)}
279289 />
280290 </View>
......@@ -283,7 +293,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
283293 icon="checkmark.circle"
284294 label="Commit"
285295 tone="primary"
286 disabled={noneSelected || busy}
296 disabled={!canWriteSourceControl || noneSelected || busy}
287297 onPress={() => void runCommitAction(false)}
288298 />
289299 </View>
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+17-4
......@@ -32,6 +32,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {
3232 const { height: windowHeight } = useWindowDimensions();
3333 const gitState = useSelectedThreadGitState();
3434 const gitActions = useSelectedThreadGitActions();
35 const { canWriteSourceControl, canChangeThreadBranch } = gitActions;
3536
3637 const params = props.route.params;
3738
......@@ -59,17 +60,25 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {
5960 );
6061
6162 const continuePendingAction = useCallback(async () => {
62 if (!confirmAction) return;
63 if (!canWriteSourceControl || !confirmAction) return;
6364 navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId }));
6465 await gitActions.onRunSelectedThreadGitAction({
6566 action: confirmAction,
6667 ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}),
6768 ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}),
6869 });
69 }, [confirmAction, environmentId, gitActions, params, navigation, threadId]);
70 }, [
71 canWriteSourceControl,
72 confirmAction,
73 environmentId,
74 gitActions,
75 params,
76 navigation,
77 threadId,
78 ]);
7079
7180 const movePendingActionToFeatureBranch = useCallback(async () => {
72 if (!confirmAction) return;
81 if (!canChangeThreadBranch || !confirmAction) return;
7382 navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId }));
7483
7584 if (includesCommit) {
......@@ -91,9 +100,11 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {
91100 branch.isRemote ? Result.failVoid : Result.succeed(branch.name),
92101 ),
93102 );
94 await gitActions.onCreateSelectedThreadBranch(newBranchName);
103 const created = await gitActions.onCreateSelectedThreadBranch(newBranchName);
104 if (created === null) return;
95105 await gitActions.onRunSelectedThreadGitAction({ action: confirmAction });
96106 }, [
107 canChangeThreadBranch,
97108 confirmAction,
98109 gitActions,
99110 gitState.selectedThreadBranches,
......@@ -164,11 +175,13 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {
164175 <SheetActionButton
165176 icon="arrow.right.circle"
166177 label={copy?.continueLabel ?? "Continue"}
178 disabled={!canWriteSourceControl}
167179 onPress={() => void continuePendingAction()}
168180 />
169181 <SheetActionButton
170182 icon="arrow.branch"
171183 label="Feature branch & continue"
184 disabled={!canChangeThreadBranch}
172185 tone="primary"
173186 onPress={() => void movePendingActionToFeatureBranch()}
174187 />
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+46-13
......@@ -87,6 +87,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
8787 );
8888 const gitState = useSelectedThreadGitState();
8989 const gitActions = useSelectedThreadGitActions();
90 const { canWriteSourceControl, canChangeThreadBranch } = gitActions;
9091 const theme = useUniwindTheme();
9192 const foregroundColor = theme["--color-foreground"];
9293 const sheetColor = theme["--color-sheet"];
......@@ -117,15 +118,21 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
117118 const sheetMenuItems = useMemo(
118119 () =>
119120 menuItems.map((item) => ({
120 item,
121 disabledReason: getGitActionDisabledReason({
122 item,
123 gitStatus: gitStatus.data,
124 isBusy: busy,
125 hasOriginRemote: hasPrimaryRemote,
126 }),
121 item: {
122 ...item,
123 disabled: item.disabled || (!canWriteSourceControl && item.kind !== "open_pr"),
124 },
125 disabledReason:
126 !canWriteSourceControl && item.kind !== "open_pr"
127 ? "This connection cannot change source control."
128 : getGitActionDisabledReason({
129 item,
130 gitStatus: gitStatus.data,
131 isBusy: busy,
132 hasOriginRemote: hasPrimaryRemote,
133 }),
127134 })),
128 [busy, gitStatus.data, hasPrimaryRemote, menuItems],
135 [busy, canWriteSourceControl, gitStatus.data, hasPrimaryRemote, menuItems],
129136 );
130137
131138 useEffect(() => {
......@@ -145,6 +152,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
145152
146153 const runActionWithPrompt = useCallback(
147154 async (input: GitActionRequestInput) => {
155 if (!canWriteSourceControl) return;
148156 const confirmableAction =
149157 input.action === "push" ||
150158 input.action === "create_pr" ||
......@@ -176,7 +184,16 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
176184 }
177185 await gitActions.onRunSelectedThreadGitAction(input);
178186 },
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 ],
180197 );
181198
182199 const onPressMenuItem = useCallback(
......@@ -186,6 +203,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
186203 await openExistingPr();
187204 return;
188205 }
206 if (!canWriteSourceControl) return;
189207 if (item.dialogAction === "commit") {
190208 navigation.navigate("GitCommit", {
191209 environmentId: String(environmentId),
......@@ -201,7 +219,14 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
201219 await runActionWithPrompt({ action: "create_pr" });
202220 }
203221 },
204 [environmentId, openExistingPr, navigation, runActionWithPrompt, threadId],
222 [
223 canWriteSourceControl,
224 environmentId,
225 openExistingPr,
226 navigation,
227 runActionWithPrompt,
228 threadId,
229 ],
205230 );
206231
207232 // Status facts live on the relevant rows instead of crowding the header
......@@ -284,8 +309,12 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
284309 <SheetListRow
285310 icon="arrow.down.circle"
286311 title="Pull latest"
287 subtitle={`${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream`}
288 disabled={busy || !isRepo}
312 subtitle={
313 canWriteSourceControl
314 ? `${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream`
315 : "This connection cannot change source control."
316 }
317 disabled={!canWriteSourceControl || busy || !isRepo}
289318 onPress={() => void gitActions.onPullSelectedThreadBranch()}
290319 />
291320 </>
......@@ -309,7 +338,11 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
309338 <SheetListRow
310339 icon="point.topleft.down.curvedto.point.bottomright.up"
311340 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 }
313346 disabled={busy || !isRepo}
314347 onPress={() =>
315348 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 {
77 type VcsActionOperation,
88 type VcsRef,
99} 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";
1116import {
1217 dedupeRemoteBranchesWithLocalMatches,
1318 sanitizeFeatureBranchName,
......@@ -20,6 +25,7 @@ import { threadEnvironment } from "../state/threads";
2025import { vcsActionManager, vcsEnvironment } from "../state/vcs";
2126import { uuidv4 } from "../lib/uuid";
2227import { appAtomRegistry } from "./atom-registry";
28import { readEnvironmentScope, useEnvironmentScope } from "./session";
2329import { setPendingConnectionError } from "./use-remote-environment-registry";
2430import { useAtomCommand } from "./use-atom-command";
2531import { showGitActionResult } from "./use-vcs-action-state";
......@@ -36,6 +42,15 @@ export function useSelectedThreadGitActions() {
3642 const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false });
3743 const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false });
3844 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;
3954 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
4055 const runStackedAction = useAtomCommand(
4156 vcsActionManager.runStackedAction({
......@@ -63,6 +78,16 @@ export function useSelectedThreadGitActions() {
6378 readonly worktreePath?: string | null;
6479 },
6580 ) => {
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 }
6691 return updateThreadMetadata({
6792 environmentId: thread.environmentId,
6893 input: {
......@@ -131,9 +156,16 @@ export function useSelectedThreadGitActions() {
131156 readonly project: EnvironmentProject;
132157 readonly cwd: string;
133158 }) => Promise<AtomCommandResult<T, E>>,
134 options?: { readonly managedExternally?: boolean },
159 options?: { readonly managedExternally?: boolean; readonly changesThreadBranch?: boolean },
135160 ): 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 ) {
137169 return null;
138170 }
139171
......@@ -180,22 +212,24 @@ export function useSelectedThreadGitActions() {
180212 readonly worktreePath?: string | null;
181213 };
182214 }): Promise<AtomCommandResult<void, unknown>> => {
183 if (input.nextThreadState) {
184 const updateResult = await updateThreadGitContext(input.thread, input.nextThreadState);
185 if (AsyncResult.isFailure(updateResult)) {
186 return AsyncResult.failure(updateResult.cause);
187 }
188 }
215 // The Git mutation already landed; refresh what the worktree shows even
216 // when the thread metadata update is denied, so the sheet does not keep
217 // displaying the previous branch.
218 const updateResult = input.nextThreadState
219 ? await updateThreadGitContext(input.thread, input.nextThreadState)
220 : AsyncResult.success(undefined);
189221 branchState.refresh();
190222 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);
192226 },
193227 [branchState, refreshSelectedThreadGitStatus, updateThreadGitContext],
194228 );
195229
196230 const onCheckoutSelectedThreadBranch = useCallback(
197231 async (branch: string) => {
198 await runSelectedThreadGitMutation(
232 return runSelectedThreadGitMutation(
199233 "switch_ref",
200234 "Switching branch",
201235 async ({ thread, cwd }) => {
......@@ -216,6 +250,7 @@ export function useSelectedThreadGitActions() {
216250 });
217251 return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result;
218252 },
253 { changesThreadBranch: true },
219254 );
220255 },
221256 [
......@@ -228,7 +263,7 @@ export function useSelectedThreadGitActions() {
228263
229264 const onCreateSelectedThreadBranch = useCallback(
230265 async (branch: string) => {
231 await runSelectedThreadGitMutation(
266 return runSelectedThreadGitMutation(
232267 "create_ref",
233268 "Creating branch",
234269 async ({ thread, cwd }) => {
......@@ -249,6 +284,7 @@ export function useSelectedThreadGitActions() {
249284 });
250285 return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result;
251286 },
287 { changesThreadBranch: true },
252288 );
253289 },
254290 [
......@@ -261,7 +297,7 @@ export function useSelectedThreadGitActions() {
261297
262298 const onCreateSelectedThreadWorktree = useCallback(
263299 async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => {
264 await runSelectedThreadGitMutation(
300 return runSelectedThreadGitMutation(
265301 "create_worktree",
266302 "Creating worktree",
267303 async ({ thread, project }) => {
......@@ -287,6 +323,7 @@ export function useSelectedThreadGitActions() {
287323 });
288324 return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result;
289325 },
326 { changesThreadBranch: true },
290327 );
291328 },
292329 [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState],
......@@ -337,14 +374,6 @@ export function useSelectedThreadGitActions() {
337374 return result;
338375 }
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
348377 if (result.value.branch.status === "created" && result.value.branch.name) {
349378 const syncResult = await syncSelectedThreadBranchState({
350379 thread,
......@@ -360,9 +389,16 @@ export function useSelectedThreadGitActions() {
360389 } else {
361390 await refreshSelectedThreadGitStatus({ quiet: true, cwd });
362391 }
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 });
363399 return result;
364400 },
365 { managedExternally: true },
401 { managedExternally: true, changesThreadBranch: input.featureBranch === true },
366402 );
367403 },
368404 [
......@@ -375,6 +411,8 @@ export function useSelectedThreadGitActions() {
375411 );
376412
377413 return {
414 canWriteSourceControl,
415 canChangeThreadBranch,
378416 refreshSelectedThreadGitStatus,
379417 refreshSelectedThreadBranches,
380418 onCheckoutSelectedThreadBranch,
apps/server/src/auth/RpcAuthorization.test.ts+61
......@@ -4,8 +4,10 @@ import {
44 AuthSettingsWriteScope,
55 DEFAULT_SERVER_SETTINGS,
66 ProviderInstanceId,
7 ThreadId,
78 AuthOrchestrationOperateScope,
89 AuthOrchestrationReadScope,
10 AuthSourceControlWriteScope,
911 AuthRelayReadScope,
1012 AuthRelayWriteScope,
1113 WS_METHODS,
......@@ -113,6 +115,28 @@ describe("RPC authorization scopes", () => {
113115 );
114116 });
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
116140 it("rejects unknown RPC method names", () => {
117141 for (const method of ["server.notRegistered", "toString", "constructor"]) {
118142 expect(() => requiredScopeForRpcMethod(method)).toThrow(
......@@ -240,3 +264,40 @@ describe("settings mutation authorization", () => {
240264 }).pipe(Effect.scoped),
241265 );
242266});
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 @@
11import {
22 CLIENT_GUARDED_RPC_SCOPES,
33 type DeviceListInput,
4 clientRpcRequiredScopes,
45 AuthAccessReadScope,
56 ServerSettingsPatch,
67 ProviderInstanceMutation,
......@@ -120,15 +121,6 @@ export const RPC_REQUIRED_SCOPES = {
120121 [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
121122 [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope,
122123 [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,
132124 // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only
133125 // client pressing refresh must not be told it may not look again.
134126 [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope,
......@@ -136,15 +128,8 @@ export const RPC_REQUIRED_SCOPES = {
136128 // The candidate list is a read like the detail beside it; asking somebody for a review is a
137129 // write like every other one.
138130 [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope,
139 [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope,
140131 [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope,
141 [WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope,
142132 [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,
148133 [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope,
149134 [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
150135 [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
......@@ -167,16 +152,8 @@ export const RPC_REQUIRED_SCOPES = {
167152 [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope,
168153 [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,
169154 [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,
170 [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope,
171 [WS_METHODS.gitRunStackedAction]: AuthOrchestrationOperateScope,
172 [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope,
173 [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope,
155 [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope,
174156 [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,
180157 [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope,
181158 [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope,
182159 [WS_METHODS.terminalOpen]: AuthTerminalOperateScope,
......@@ -246,13 +223,20 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => {
246223 : [...new Set([...scopes, AuthProvidersManageScope])];
247224};
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
249236/** Authorizes every RPC on one connection against that connection's session scopes. */
250237export const layer = (scopes: ReadonlyArray<AuthEnvironmentScope>) =>
251238 Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) => {
252 const requiredScopes =
253 rpc._tag === WS_METHODS.serverUpdateSettings
254 ? requiredScopesForSettingsUpdate(payload)
255 : [requiredScopeForRpcMethod(rpc._tag)];
239 const requiredScopes = requiredScopesForRpcCall(rpc._tag, payload);
256240 const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope));
257241 return requiredScope === undefined ? effect : Effect.fail(rpcAuthorizationError(requiredScope));
258242 });
apps/server/src/auth/http.ts+2
......@@ -10,6 +10,7 @@ import {
1010 AuthRelayReadScope,
1111 AuthRelayWriteScope,
1212 AuthReviewWriteScope,
13 AuthSourceControlWriteScope,
1314 AuthTerminalOperateScope,
1415 EnvironmentAuthInvalidError,
1516 type EnvironmentAuthInvalidReason,
......@@ -374,6 +375,7 @@ export const layer = HttpApiBuilder.group(
374375 AuthEnvironmentMaintainScope,
375376 AuthTerminalOperateScope,
376377 AuthReviewWriteScope,
378 AuthSourceControlWriteScope,
377379 AuthAccessReadScope,
378380 AuthAccessWriteScope,
379381 AuthRelayReadScope,
apps/web/src/components/BranchPicker.tsx+3
......@@ -264,6 +264,7 @@ export function BranchPickerRefItem({
264264 projectCwd: activeProjectCwd,
265265 index,
266266 value,
267 disabled,
267268 onClick,
268269 onContextMenu,
269270}: {
......@@ -271,6 +272,7 @@ export function BranchPickerRefItem({
271272 projectCwd: string | null;
272273 index: number;
273274 value?: string;
275 disabled?: boolean;
274276 onClick: ComponentProps<typeof ComboboxItem>["onClick"];
275277 onContextMenu?: ComponentProps<typeof ComboboxItem>["onContextMenu"];
276278}) {
......@@ -288,6 +290,7 @@ export function BranchPickerRefItem({
288290 : null;
289291 return (
290292 <ComboboxItem
293 disabled={disabled}
291294 hideIndicator
292295 key={itemValue}
293296 index={index}
apps/web/src/components/BranchToolbarBranchSelector.tsx+51-5
......@@ -8,7 +8,14 @@ import {
88 isAtomCommandInterrupted,
99 squashAtomCommandFailure,
1010} 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";
1219import { ChevronDownIcon, GitBranchIcon } from "lucide-react";
1320import {
1421 useCallback,
......@@ -30,6 +37,7 @@ import { useOpenPrLink } from "../lib/openPullRequestLink";
3037import { usePaginatedBranches } from "../state/queries";
3138import { useProject, useThreadShell } from "../state/entities";
3239import { useEnvironmentQuery } from "../state/query";
40import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
3341import { threadEnvironment } from "../state/threads";
3442import { useAtomCommand } from "../state/use-atom-command";
3543import { vcsEnvironment } from "../state/vcs";
......@@ -110,6 +118,8 @@ export function BranchToolbarBranchSelector({
110118 onComposerFocusRequest,
111119}: BranchToolbarBranchSelectorProps) {
112120 const composerFloatingLayerProps = useComposerMenuProps();
121 const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope);
122 const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
113123 const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop");
114124 const updateThreadMetadata = useAtomCommand(
115125 threadEnvironment.updateMetadata,
......@@ -153,6 +163,8 @@ export function BranchToolbarBranchSelector({
153163 const activeProjectCwd = activeProject?.workspaceRoot ?? null;
154164 const branchCwd = activeWorktreePath ?? activeProjectCwd;
155165 const hasServerThread = serverThread !== null;
166 const canUpdateThreadBranch = !hasServerThread || canOperateThread;
167 const canChangeThreadBranch = canWriteSourceControl && canUpdateThreadBranch;
156168 const effectiveEnvMode =
157169 effectiveEnvModeOverride ??
158170 resolveEffectiveEnvMode({
......@@ -166,7 +178,12 @@ export function BranchToolbarBranchSelector({
166178 // ---------------------------------------------------------------------------
167179 const setThreadBranch = useCallback(
168180 (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;
170187 if (serverSession && worktreePath !== activeWorktreePath) {
171188 void stopThreadSession({
172189 environmentId,
......@@ -273,8 +290,11 @@ export function BranchToolbarBranchSelector({
273290 const isSelectingWorktreeBase =
274291 effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath;
275292 const checkoutPullRequestItemValue =
276 prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null;
277 const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0;
293 canChangeThreadBranch && prReference && onCheckoutPullRequestRequest
294 ? `__checkout_pull_request__:${prReference}`
295 : null;
296 const canCreateBranch =
297 canChangeThreadBranch && !isSelectingWorktreeBase && trimmedBranchQuery.length > 0;
278298 // The ref is created under its sanitized name, so the collision check has to
279299 // use that name too. Matching on the raw query would offer to create a ref
280300 // that already exists whenever sanitizing changes the name.
......@@ -392,6 +412,20 @@ export function BranchToolbarBranchSelector({
392412 );
393413
394414 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 }
395429 startBranchActionTransition(async () => {
396430 await action();
397431 branchRefState.refresh();
......@@ -400,7 +434,7 @@ export function BranchToolbarBranchSelector({
400434 };
401435
402436 const selectBranch = (refName: VcsRef) => {
403 if (!branchCwd || !activeProjectCwd || isBranchActionPending) return;
437 if (!canUpdateThreadBranch || !branchCwd || !activeProjectCwd || isBranchActionPending) return;
404438
405439 if (isSelectingWorktreeBase) {
406440 setThreadBranch(refName.name, null);
......@@ -461,6 +495,7 @@ export function BranchToolbarBranchSelector({
461495 };
462496
463497 const createRef = (rawName: string) => {
498 if (!canChangeThreadBranch) return;
464499 const name = sanitizeNewRefName(rawName);
465500 if (!branchCwd || !name || isBranchActionPending) return;
466501
......@@ -647,6 +682,17 @@ export function BranchToolbarBranchSelector({
647682 projectCwd={activeProjectCwd}
648683 index={index}
649684 value={itemValue}
685 disabled={
686 !canUpdateThreadBranch ||
687 (!canWriteSourceControl &&
688 !isSelectingWorktreeBase &&
689 (!activeProjectCwd ||
690 !resolveBranchSelectionTarget({
691 activeProjectCwd,
692 activeWorktreePath,
693 refName,
694 }).reuseExistingWorktree))
695 }
650696 onClick={() => selectPickerItem(itemValue)}
651697 onContextMenu={(event) => handleBranchContextMenu(event, itemValue)}
652698 />
apps/web/src/components/ChatView.tsx+20-8
......@@ -50,6 +50,7 @@ import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue";
5050import {
5151 AuthOrchestrationOperateScope,
5252 AuthSettingsWriteScope,
53 AuthSourceControlWriteScope,
5354 type AssistantCitation,
5455 type ChatFileAttachment,
5556 CommandId,
......@@ -397,6 +398,7 @@ import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnect
397398import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
398399import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
399400import { useEnvironmentQuery } from "../state/query";
401import { useEnvironmentScope } from "~/state/session";
400402import {
401403 environmentServerConfigsAtom,
402404 primaryServerAvailableEditorsAtom,
......@@ -559,11 +561,7 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at
559561import { appAtomRegistry } from "../rpc/atomRegistry";
560562import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles";
561563import { assetEnvironment } from "../state/assets";
562import {
563 readEnvironmentScope,
564 readPreparedConnection,
565 useEnvironmentScope,
566} from "../state/session";
564import { readEnvironmentScope, readPreparedConnection } from "../state/session";
567565import { useAtomCommand } from "../state/use-atom-command";
568566import { useOrchestrationCommand } from "../state/use-orchestration-command";
569567import { useAtomQueryRunner } from "../state/use-atom-query-runner";
......@@ -1577,6 +1575,7 @@ export default function ChatView(props: ChatViewProps) {
15771575 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
15781576 reportFailure: false,
15791577 });
1578 const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope);
15801579 const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
15811580 const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, {
15821581 reportFailure: false,
......@@ -2164,7 +2163,7 @@ export default function ChatView(props: ChatViewProps) {
21642163 (isServerThread ? activeThread?.runtimeMode : undefined) ??
21652164 defaultRuntimeMode;
21662165 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
2167 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
2166 const canCheckoutPullRequestIntoThread = canWriteSourceControl && isLocalDraftThread;
21682167 const activeThreadId = activeThread?.id ?? null;
21692168 // Prefer the larger of turn-item-committed ids and projection messages so
21702169 // env lock does not unlock while turn items lag projection hydration.
......@@ -7161,6 +7160,7 @@ export default function ChatView(props: ChatViewProps) {
71617160 });
71627161 }, [activeBranchMismatchKey, showBranchMismatchBanner]);
71637162 const handleSwitchCheckoutToThread = useCallback(async () => {
7163 if (!canWriteSourceControl) return;
71647164 if (
71657165 !activeProjectCwd ||
71667166 !activeThread ||
......@@ -7191,6 +7191,10 @@ export default function ChatView(props: ChatViewProps) {
71917191 return;
71927192 }
71937193
7194 if (!readEnvironmentScope(environmentId, AuthSourceControlWriteScope)) {
7195 setIsRestoringThreadBranch(false);
7196 return;
7197 }
71947198 const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch;
71957199 if (nextBranch !== activeThread.branch) {
71967200 const updateResult = await updateThreadMetadata({
......@@ -7216,6 +7220,7 @@ export default function ChatView(props: ChatViewProps) {
72167220 setIsRestoringThreadBranch(false);
72177221 scheduleComposerFocus();
72187222 }, [
7223 canWriteSourceControl,
72197224 activeProjectCwd,
72207225 activeThread,
72217226 environmentId,
......@@ -7622,12 +7627,17 @@ export default function ChatView(props: ChatViewProps) {
76227627 selectedProvider,
76237628 ]);
76247629 const handleRestoreThreadBranch = useCallback(() => {
7630 if (!canWriteSourceControl) return;
76257631 if (gitStatusQuery.data?.hasWorkingTreeChanges) {
76267632 setBranchRestoreConfirmOpen(true);
76277633 return;
76287634 }
76297635 void handleSwitchCheckoutToThread();
7630 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
7636 }, [
7637 canWriteSourceControl,
7638 gitStatusQuery.data?.hasWorkingTreeChanges,
7639 handleSwitchCheckoutToThread,
7640 ]);
76317641 const feedbackBannerItems = useMemo(
76327642 () =>
76337643 feedbackSubmissions.flatMap((submission) => {
......@@ -7722,7 +7732,7 @@ export default function ChatView(props: ChatViewProps) {
77227732 <Button
77237733 size="xs"
77247734 variant="ghost"
7725 disabled={isRestoringThreadBranch}
7735 disabled={!canWriteSourceControl || isRestoringThreadBranch}
77267736 onClick={handleRestoreThreadBranch}
77277737 >
77287738 {isRestoringThreadBranch ? "Restoring..." : "Restore branch"}
......@@ -7740,6 +7750,7 @@ export default function ChatView(props: ChatViewProps) {
77407750 activeBranchMismatchKey,
77417751 activeThreadShell,
77427752 serverRuntime?.usageLimitResetAt,
7753 canWriteSourceControl,
77437754 feedbackBannerItems,
77447755 limitRecoveryBanner,
77457756 handleRestoreThreadBranch,
......@@ -11654,6 +11665,7 @@ export default function ChatView(props: ChatViewProps) {
1165411665 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
1165511666 <Button
1165611667 variant="default"
11668 disabled={!canWriteSourceControl}
1165711669 onClick={() => {
1165811670 setBranchRestoreConfirmOpen(false);
1165911671 void handleSwitchCheckoutToThread();
apps/web/src/components/CommandPalette.tsx+45-4
......@@ -30,6 +30,8 @@ import {
3030 squashAtomCommandFailure,
3131} from "@t3tools/client-runtime/state/runtime";
3232import {
33 AuthOrchestrationOperateScope,
34 AuthSourceControlWriteScope,
3335 type DesktopWslState,
3436 type EnvironmentId,
3537 type EnvironmentMachineKind,
......@@ -103,6 +105,7 @@ import { projectEnvironment } from "../state/projects";
103105import { useEnvironmentQuery } from "../state/query";
104106import { serverEnvironment } from "../state/server";
105107import { threadEnvironment } from "../state/threads";
108import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
106109import { sourceControlEnvironment } from "../state/sourceControl";
107110import { useAtomCommand } from "../state/use-atom-command";
108111import { useAtomQueryRunner } from "../state/use-atom-query-runner";
......@@ -1039,6 +1042,7 @@ function OpenCommandPaletteDialog(props: {
10391042 [addProjectEnvironmentOptions, environments],
10401043 );
10411044 const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId;
1045 const canCreateProject = useEnvironmentScope(browseEnvironmentId, AuthOrchestrationOperateScope);
10421046 const browseEnvironment =
10431047 environments.find((environment) => environment.environmentId === browseEnvironmentId) ?? null;
10441048 // A desktop-local secondary backend (today: the WSL backend). The picker is
......@@ -2418,6 +2422,16 @@ function OpenCommandPaletteDialog(props: {
24182422 return;
24192423 }
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 }
24212435 const projectId = newProjectId();
24222436 const createResult = await createProject({
24232437 environmentId: input.environmentId,
......@@ -2607,9 +2621,20 @@ function OpenCommandPaletteDialog(props: {
26072621 }
26082622
26092623 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 );
26112635 return;
26122636 }
2637 if (rawDestination.length === 0 || isRemoteProjectCloning) return;
26132638
26142639 if (isUnsupportedWindowsProjectPath(rawDestination, browseEnvironmentPlatform)) {
26152640 toastManager.add(
......@@ -2948,9 +2973,21 @@ function OpenCommandPaletteDialog(props: {
29482973 getCommandPaletteInputPlaceholder(paletteMode));
29492974 const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse";
29502975 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";
29512986 const canSubmitBrowsePath =
29522987 isBrowsing &&
29532988 !relativePathNeedsActiveProject &&
2989 canCreateProject &&
2990 (!isCloneDestinationStep || canCloneProject) &&
29542991 canCreateProjectInEnvironment(browseEnvironment?.connection.phase);
29552992 const willCreateProjectPath =
29562993 canSubmitBrowsePath &&
......@@ -2960,7 +2997,6 @@ function OpenCommandPaletteDialog(props: {
29602997 (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null);
29612998 const useMetaForMod = isMacPlatform(navigator.platform);
29622999 const submitModifierLabel = useMetaForMod ? "\u2318" : "Ctrl";
2963 const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm";
29643000 const submitActionLabel = isCloneDestinationStep
29653001 ? willCreateProjectPath
29663002 ? "Create & Clone"
......@@ -3327,9 +3363,10 @@ function OpenCommandPaletteDialog(props: {
33273363 className="absolute inset-e-2.5 top-1/2 -translate-y-1/2"
33283364 aria-label={`${submitActionLabel} (${addShortcutLabel})`}
33293365 disabled={
3366 !canCreateProject ||
33303367 !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) ||
33313368 relativePathNeedsActiveProject ||
3332 (isCloneDestinationStep && isRemoteProjectPending)
3369 (isCloneDestinationStep && (!canCloneProject || isRemoteProjectPending))
33333370 }
33343371 onMouseDown={(event) => {
33353372 event.preventDefault();
......@@ -3355,7 +3392,11 @@ function OpenCommandPaletteDialog(props: {
33553392 </KbdGroup>
33563393 </TooltipTrigger>
33573394 <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."}
33593400 </TooltipPopup>
33603401 </Tooltip>
33613402 ) : 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 @@
11import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";
22import { useAtomValue } from "@effect/atom-react";
3import { type ScopedThreadRef } from "@t3tools/contracts";
3import {
4 AuthOrchestrationOperateScope,
5 AuthSourceControlWriteScope,
6 type ScopedThreadRef,
7} from "@t3tools/contracts";
48import {
59 isAtomCommandInterrupted,
610 squashAtomCommandFailure,
......@@ -108,7 +112,8 @@ import {
108112 useVcsInitAction,
109113 useVcsPullAction,
110114} from "~/lib/sourceControlActions";
111import { useThreadShell } from "~/state/entities";
115import { useThreadProjection, useThreadShell } from "~/state/entities";
116import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
112117import { useEnvironmentQuery } from "~/state/query";
113118import { serverEnvironment } from "~/state/server";
114119import { sourceControlEnvironment } from "~/state/sourceControl";
......@@ -630,14 +635,19 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
630635 ] as const;
631636
632637 const canSubmitPublishRepository = useMemo(() => {
633 if (!selectedPublishProviderReadiness.ready) return false;
638 if (!publishRepositoryAction.isAllowed || !selectedPublishProviderReadiness.ready) return false;
634639 if (publishRepositoryAction.isPending) return false;
635640 const repositoryParts = publishRepository.trim().split("/");
636641 const owner = repositoryParts[0]?.trim() ?? "";
637642 const rest = repositoryParts.slice(1);
638643 const name = rest.join("/").trim();
639644 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
642652 const submitPublishRepository = useCallback(() => {
643653 if (!canSubmitPublishRepository) {
......@@ -1070,6 +1080,11 @@ export default function GitActionsControl({
10701080 );
10711081 const activeEnvironmentId = activeThreadRef?.environmentId ?? null;
10721082 const successScopeKey = `${activeEnvironmentId ?? ""}\u0000${gitCwd ?? ""}`;
1083 const canWriteSourceControl = useEnvironmentScope(
1084 activeEnvironmentId,
1085 AuthSourceControlWriteScope,
1086 );
1087 const canOperateThread = useEnvironmentScope(activeEnvironmentId, AuthOrchestrationOperateScope);
10731088 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(activeEnvironmentId));
10741089 const openInPreferredEditor = useOpenInPreferredEditor(
10751090 activeEnvironmentId,
......@@ -1079,7 +1094,7 @@ export default function GitActionsControl({
10791094 () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined),
10801095 [activeThreadRef],
10811096 );
1082 const activeServerThread = useThreadShell(activeThreadRef);
1097 const activeServerThreadShell = useThreadShell(activeThreadRef);
10831098 const openPrLink = useOpenPrLink(activeThreadRef ?? undefined);
10841099 const activeDraftThread = useComposerDraftStore((store) =>
10851100 draftId
......@@ -1088,6 +1103,13 @@ export default function GitActionsControl({
10881103 ? store.getDraftThreadByRef(activeThreadRef)
10891104 : null,
10901105 );
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);
10911113 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
10921114 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
10931115 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
......@@ -1120,7 +1142,10 @@ export default function GitActionsControl({
11201142 }
11211143
11221144 if (activeServerThread) {
1123 if (activeServerThread.branch === branch) {
1145 if (
1146 !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) ||
1147 activeServerThread.branch === branch
1148 ) {
11241149 return;
11251150 }
11261151
......@@ -1248,9 +1273,11 @@ export default function GitActionsControl({
12481273 resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote),
12491274 [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning],
12501275 );
1251 const quickActionDisabledReason = quickAction.disabled
1252 ? (quickAction.hint ?? "This action is currently unavailable.")
1253 : null;
1276 const quickActionDisabledReason = !canWriteSourceControl
1277 ? "This connection cannot change source control."
1278 : quickAction.disabled
1279 ? (quickAction.hint ?? "This action is currently unavailable.")
1280 : null;
12541281 const gitActionProgress = resolveGitActionProgressPresentation(vcsActionState);
12551282 const pendingDefaultBranchActionCopy = pendingDefaultBranchAction
12561283 ? resolveDefaultBranchActionDialogCopy({
......@@ -1304,6 +1331,14 @@ export default function GitActionsControl({
13041331 featureBranch = false,
13051332 filePaths,
13061333 }: RunGitActionWithToastInput) => {
1334 if (
1335 activeEnvironmentId === null ||
1336 !readEnvironmentScope(activeEnvironmentId, AuthSourceControlWriteScope) ||
1337 (featureBranch &&
1338 !isLocalDraftThread &&
1339 !readEnvironmentScope(activeEnvironmentId, AuthOrchestrationOperateScope))
1340 )
1341 return;
13071342 const actionStatus = statusOverride ?? gitStatusForActions;
13081343 const actionBranch = actionStatus?.refName ?? null;
13091344 const actionIsDefaultBranch = featureBranch ? false : isDefaultRef;
......@@ -1459,7 +1494,7 @@ export default function GitActionsControl({
14591494 };
14601495
14611496 const checkoutFeatureBranchAndContinuePendingAction = () => {
1462 if (!pendingDefaultBranchAction) return;
1497 if (!canChangeThreadBranch || !pendingDefaultBranchAction) return;
14631498 const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction;
14641499 setPendingDefaultBranchAction(null);
14651500 void runGitActionWithToast({
......@@ -1473,7 +1508,7 @@ export default function GitActionsControl({
14731508 };
14741509
14751510 const runDialogActionOnNewBranch = () => {
1476 if (!isCommitDialogOpen) return;
1511 if (!canChangeThreadBranch || !isCommitDialogOpen) return;
14771512 const commitMessage = dialogCommitMessage.trim();
14781513
14791514 setIsCommitDialogOpen(false);
......@@ -1616,7 +1651,8 @@ export default function GitActionsControl({
16161651 [gitCwd, openInPreferredEditor, threadToastData],
16171652 );
16181653
1619 const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote;
1654 const canPublishRepository =
1655 canWriteSourceControl && isRepo && gitStatusForActions !== null && !hasPrimaryRemote;
16201656
16211657 const initializeGit = () => {
16221658 void (async () => {
......@@ -1683,7 +1719,7 @@ export default function GitActionsControl({
16831719 <MenuItem
16841720 density={presentation === "menu" ? "touch" : "default"}
16851721 key={`${item.id}-${item.label}`}
1686 disabled={item.disabled}
1722 disabled={!canWriteSourceControl || item.disabled}
16871723 onClick={() => {
16881724 openDialogForMenuItem(item);
16891725 }}
......@@ -1777,7 +1813,7 @@ export default function GitActionsControl({
17771813 variant={isPanel ? "ghost" : "outline"}
17781814 part="row"
17791815 panel={isPanel}
1780 disabled={initAction.isPending}
1816 disabled={!canWriteSourceControl || initAction.isPending}
17811817 onClick={initializeGit}
17821818 >
17831819 <GitBranchPlusIcon className="size-3.5" aria-hidden />
......@@ -1855,7 +1891,7 @@ export default function GitActionsControl({
18551891 size="xs"
18561892 part="primary"
18571893 panel={isPanel}
1858 disabled={isGitActionRunning || quickAction.disabled}
1894 disabled={!canWriteSourceControl || isGitActionRunning || quickAction.disabled}
18591895 onClick={runQuickAction}
18601896 >
18611897 <GitQuickActionIcon
......@@ -2035,6 +2071,8 @@ export default function GitActionsControl({
20352071 <button
20362072 type="button"
20372073 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`}
20382076 onClick={() => openChangedFileInEditor(file.path)}
20392077 >
20402078 <StartTruncatedPath
......@@ -2102,12 +2140,16 @@ export default function GitActionsControl({
21022140 <Button
21032141 variant="outline"
21042142 size="sm"
2105 disabled={noneSelected}
2143 disabled={!canChangeThreadBranch || noneSelected}
21062144 onClick={runDialogActionOnNewBranch}
21072145 >
21082146 Commit on new branch
21092147 </Button>
2110 <Button size="sm" disabled={noneSelected} onClick={runDialogAction}>
2148 <Button
2149 size="sm"
2150 disabled={!canWriteSourceControl || noneSelected}
2151 onClick={runDialogAction}
2152 >
21112153 Commit
21122154 </Button>
21132155 </DialogFooter>
......@@ -2151,6 +2193,7 @@ export default function GitActionsControl({
21512193 variant="outline"
21522194 size="sm-multiline"
21532195 onClick={continuePendingDefaultBranchAction}
2196 disabled={!canWriteSourceControl}
21542197 >
21552198 {pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"}
21562199 </Button>
......@@ -2158,6 +2201,7 @@ export default function GitActionsControl({
21582201 className="w-full max-w-full sm:w-auto"
21592202 size="sm-multiline"
21602203 onClick={checkoutFeatureBranchAndContinuePendingAction}
2204 disabled={!canChangeThreadBranch}
21612205 >
21622206 Check out feature branch & continue
21632207 </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 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
1import {
2 AuthOrchestrationOperateScope,
3 type EnvironmentId,
4 type ThreadId,
5} from "@t3tools/contracts";
6import {
7 isAtomCommandInterrupted,
8 squashAtomCommandFailure,
9} from "@t3tools/client-runtime/state/runtime";
310import { useDebouncedValue } from "@tanstack/react-pacer";
411import { useCallback, useEffect, useMemo, useRef, useState } from "react";
512
......@@ -12,6 +19,7 @@ import { cn } from "~/lib/utils";
1219import { parsePullRequestReference } from "~/pullRequestReference";
1320import { getSourceControlPresentation } from "~/sourceControlPresentation";
1421import { useEnvironmentQuery } from "~/state/query";
22import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
1523import { vcsEnvironment } from "~/state/vcs";
1624import { Button } from "./ui/button";
1725import {
......@@ -49,6 +57,7 @@ export function PullRequestThreadDialog({
4957 const [reference, setReference] = useState(initialReference ?? "");
5058 const [referenceDirty, setReferenceDirty] = useState(false);
5159 const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null);
60 const [prepareErrorMessage, setPrepareErrorMessage] = useState<string | null>(null);
5261 const [debouncedReference, referenceDebouncer] = useDebouncedValue(
5362 reference,
5463 { wait: 450 },
......@@ -102,6 +111,7 @@ export function PullRequestThreadDialog({
102111 );
103112 }, [parsedReference, sourceControlScope]);
104113 const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope);
114 const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
105115
106116 const liveResolvedPullRequest =
107117 parsedReference !== null && parsedReference === parsedDebouncedReference
......@@ -131,6 +141,13 @@ export function PullRequestThreadDialog({
131141
132142 const handleConfirm = useCallback(
133143 async (mode: "local" | "worktree") => {
144 if (!preparePullRequestThreadAction.isAllowed) return;
145 if (
146 mode === "worktree" &&
147 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
148 ) {
149 return;
150 }
134151 if (!parsedReference) {
135152 setReferenceDirty(true);
136153 return;
......@@ -138,6 +155,7 @@ export function PullRequestThreadDialog({
138155 if (!parsedReference || !resolvedPullRequest || !cwd) {
139156 return;
140157 }
158 setPrepareErrorMessage(null);
141159 setPreparingMode(mode);
142160 const result = await preparePullRequestThreadAction.run({
143161 reference: parsedReference,
......@@ -148,6 +166,13 @@ export function PullRequestThreadDialog({
148166 if (result._tag === "Failure") {
149167 if (isAtomCommandInterrupted(result)) {
150168 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 );
151176 }
152177 return;
153178 }
......@@ -159,11 +184,13 @@ export function PullRequestThreadDialog({
159184 },
160185 [
161186 cwd,
187 environmentId,
162188 onOpenChange,
163189 onPrepared,
164190 parsedReference,
165191 preparePullRequestThreadAction,
166192 resolvedPullRequest,
193 terminology.singular,
167194 threadId,
168195 ],
169196 );
......@@ -177,6 +204,7 @@ export function PullRequestThreadDialog({
177204 : null;
178205 const errorMessage =
179206 validationMessage ??
207 prepareErrorMessage ??
180208 (resolvedPullRequest === null && pullRequestResolution.error
181209 ? pullRequestResolution.error
182210 : preparePullRequestThreadAction.error instanceof Error
......@@ -273,10 +301,9 @@ export function PullRequestThreadDialog({
273301 type="button"
274302 size="sm"
275303 variant="outline"
276 onClick={() => {
277 void handleConfirm("local");
278 }}
304 onClick={() => handleConfirm("local")}
279305 disabled={
306 !preparePullRequestThreadAction.isAllowed ||
280307 !cwd ||
281308 !resolvedPullRequest ||
282309 isResolving ||
......@@ -288,10 +315,10 @@ export function PullRequestThreadDialog({
288315 <Button
289316 type="button"
290317 size="sm"
291 onClick={() => {
292 void handleConfirm("worktree");
293 }}
318 onClick={() => handleConfirm("worktree")}
294319 disabled={
320 !preparePullRequestThreadAction.isAllowed ||
321 !canOperateThread ||
295322 !cwd ||
296323 !resolvedPullRequest ||
297324 isResolving ||
apps/web/src/components/Sidebar.tsx+183-61
......@@ -1,3 +1,4 @@
1import { type EnvironmentId } from "@t3tools/contracts";
12import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard";
23import { CollapsibleSectionHeader } from "./ui/collapsible-section-header";
34import { setThreadChangeRequestSnapshot } from "./ThreadStatusIndicators";
......@@ -52,6 +53,7 @@ import {
5253 scopedThreadKey,
5354} from "@t3tools/client-runtime/environment";
5455import {
56 AuthOrchestrationOperateScope,
5557 type EnvironmentMachineKind,
5658 type ScopedThreadRef,
5759 type ThreadId,
......@@ -158,7 +160,8 @@ import { vcsEnvironment } from "../state/vcs";
158160import { threadEnvironment } from "../state/threads";
159161import { useEnvironmentQuery } from "../state/query";
160162import { useThreadSearch } from "../state/queries";
161import { useAtomCommand } from "../state/use-atom-command";
163import { useOrchestrationCommand } from "../state/use-orchestration-command";
164import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
162165import {
163166 buildThreadRouteParams,
164167 resolveActiveThreadRouteRef,
......@@ -170,7 +173,11 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
170173import { cn } from "~/lib/utils";
171174import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
172175import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge";
173import { buildDraftActionMenuItems, buildThreadActionMenuItems } from "./threadActionMenu.logic";
176import {
177 buildDraftActionMenuItems,
178 buildThreadActionMenuItems,
179 threadActionRequiresOperate,
180} from "./threadActionMenu.logic";
174181import {
175182 animateSidebarLayoutChanges,
176183 applySidebarThreadDrop,
......@@ -296,6 +303,26 @@ const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded";
296303// Module scope keeps the inbox order across routes that unmount the sidebar.
297304const 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
299326function compactSidebarTimeLabel(label: string): string {
300327 if (label === "just now") return "now";
301328 return label.endsWith(" ago") ? label.slice(0, -4) : label;
......@@ -619,11 +646,13 @@ function SortableThreadRow(props: {
619646 id: string;
620647 disabled: boolean;
621648 contextDrag: boolean;
649 environmentId: EnvironmentId;
622650 children: (bag: SortableThreadRowBag) => ReactNode;
623651}) {
652 const canOperateThread = useEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope);
624653 const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
625654 id: props.id,
626 disabled: { draggable: props.disabled },
655 disabled: { draggable: props.disabled || !canOperateThread },
627656 animateLayoutChanges: animateSidebarLayoutChanges,
628657 });
629658 // dnd-kit memoizes each field but not the bag, so the memoized row would
......@@ -1173,6 +1202,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
11731202 [thread.environmentId, thread.id],
11741203 );
11751204 const threadKey = scopedThreadKey(threadRef);
1205 const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope);
1206 useEffect(() => {
1207 if (!canOperateThread && isRenaming) onCancelRename();
1208 }, [canOperateThread, isRenaming, onCancelRename]);
11761209 const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive);
11771210 const isRegeneratingTitle = thread.titleRegeneration != null;
11781211 const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
......@@ -1389,7 +1422,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
13891422 );
13901423 const handleDoubleClick = useCallback(
13911424 (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 ) {
13931433 return;
13941434 }
13951435 if ((event.target as HTMLElement).closest("button, a, input")) return;
......@@ -1497,7 +1537,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14971537 // Snooze is offered only where it can succeed: capability-gated and never
14981538 // on blocked-on-you work or queued turns (the server rejects both).
14991539 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;
15011545 // If the thread becomes blocked while the popover is open, the button
15021546 // unmounts without firing onOpenChange(false). Deriving the flag keeps a
15031547 // stale true from permanently hiding the status label / pinning the
......@@ -1600,52 +1644,53 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
16001644 isActive: props.isActive,
16011645 });
16021646
1603 const title = isRenaming ? (
1604 <input
1605 autoFocus
1606 value={renamingTitle}
1607 aria-label="Thread title"
1608 onChange={(event) => onRenameTitleChange(event.target.value)}
1609 onFocus={(event) => event.currentTarget.select()}
1610 onKeyDown={handleRenameKeyDown}
1611 onBlur={handleRenameBlur}
1612 onClick={(event) => event.stopPropagation()}
1613 onDoubleClick={(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"
1615 />
1616 ) : (
1617 <span
1618 aria-hidden
1619 className={cn(
1620 "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none",
1621 shouldRecede ? "font-normal" : "font-medium",
1622 variant === "card"
1623 ? cn(
1624 "truncate",
1625 shouldRecede
1626 ? "text-secondary-label"
1627 : isUnread || isWoke || status === "input"
1628 ? "text-foreground"
1629 : status === "failed"
1630 ? "text-foreground/95"
1631 : "text-foreground/90",
1632 )
1633 : cn(
1634 "truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground",
1635 shouldRecede
1636 ? "text-secondary-label/70"
1637 : props.isActive || isWoke || status === "input"
1638 ? "text-foreground"
1639 : isUnread
1640 ? "text-muted-foreground"
1641 : "text-secondary-label/70",
1642 ),
1643 isRegeneratingTitle && "opacity-55",
1644 )}
1645 >
1646 {thread.title}
1647 </span>
1648 );
1647 const title =
1648 isRenaming && canOperateThread ? (
1649 <input
1650 autoFocus
1651 value={renamingTitle}
1652 aria-label="Thread title"
1653 onChange={(event) => onRenameTitleChange(event.target.value)}
1654 onFocus={(event) => event.currentTarget.select()}
1655 onKeyDown={handleRenameKeyDown}
1656 onBlur={handleRenameBlur}
1657 onClick={(event) => event.stopPropagation()}
1658 onDoubleClick={(event) => event.stopPropagation()}
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"
1660 />
1661 ) : (
1662 <span
1663 aria-hidden
1664 className={cn(
1665 "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none",
1666 shouldRecede ? "font-normal" : "font-medium",
1667 variant === "card"
1668 ? cn(
1669 "truncate",
1670 shouldRecede
1671 ? "text-secondary-label"
1672 : isUnread || isWoke || status === "input"
1673 ? "text-foreground"
1674 : status === "failed"
1675 ? "text-foreground/95"
1676 : "text-foreground/90",
1677 )
1678 : cn(
1679 "truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground",
1680 shouldRecede
1681 ? "text-secondary-label/70"
1682 : props.isActive || isWoke || status === "input"
1683 ? "text-foreground"
1684 : isUnread
1685 ? "text-muted-foreground"
1686 : "text-secondary-label/70",
1687 ),
1688 isRegeneratingTitle && "opacity-55",
1689 )}
1690 >
1691 {thread.title}
1692 </span>
1693 );
16491694 const accessibleTitle = isRenaming ? null : <span className="sr-only">{thread.title}</span>;
16501695
16511696 // Stacks show their layer count; multiple unrelated links show their total count.
......@@ -1706,7 +1751,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
17061751 const showPin =
17071752 props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null));
17081753 const pinIndicator = showPin ? (
1709 props.pinningSupported && !sortable?.isDragging ? (
1754 props.pinningSupported && canOperateThread && !sortable?.isDragging ? (
17101755 <Tooltip>
17111756 <TooltipTrigger
17121757 render={
......@@ -2350,7 +2395,7 @@ export default function Sidebar() {
23502395 archiveThread,
23512396 deleteThread,
23522397 } = useThreadActions();
2353 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
2398 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
23542399 reportFailure: false,
23552400 });
23562401 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
......@@ -3180,6 +3225,7 @@ export default function Sidebar() {
31803225 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
31813226 const [renamingTitle, setRenamingTitle] = useState("");
31823227 const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => {
3228 if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return;
31833229 setRenamingThreadKey(scopedThreadKey(threadRef));
31843230 setRenamingTitle(title);
31853231 }, []);
......@@ -3187,6 +3233,7 @@ export default function Sidebar() {
31873233 const commitThreadRename = useCallback(
31883234 (threadRef: ScopedThreadRef, title: string, originalTitle: string) => {
31893235 void (async () => {
3236 if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return;
31903237 const trimmed = title.trim();
31913238 setRenamingThreadKey(null);
31923239 if (trimmed.length === 0) {
......@@ -3960,6 +4007,17 @@ export default function Sidebar() {
39604007 : plan.kind === "reorder-pinned" || plan.kind === "move-active"
39614008 ? plan.assignments
39624009 : [];
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;
39634021 const drop = {
39644022 key: activeKey,
39654023 sourceSection: activeSection,
......@@ -4174,6 +4232,9 @@ export default function Sidebar() {
41744232 const thread = threadByKeyRef.current.get(threadKey);
41754233 return thread ? [thread] : [];
41764234 });
4235 const settlingThreads = selectedThreads.filter(
4236 (thread) => thread.settledOverride !== "settled",
4237 );
41774238 const canSnoozeSelection = selectedThreads.every(
41784239 (thread) =>
41794240 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true &&
......@@ -4206,31 +4267,73 @@ export default function Sidebar() {
42064267 const clicked = await settlePromise(() =>
42074268 api.contextMenu.show(
42084269 [
4209 ...(unpinMenuItem ? [unpinMenuItem] : []),
4210 { id: "settle", label: `Settle (${count})` },
4270 ...(unpinMenuItem
4271 ? [{ ...unpinMenuItem, disabled: !canOperateThreads(pinnedSelectedThreads) }]
4272 : []),
4273 {
4274 id: "settle",
4275 label: `Settle (${count})`,
4276 disabled: settlingThreads.length === 0 || !canOperateThreads(settlingThreads),
4277 },
42114278 ...(canSnoozeSelection
42124279 ? [
42134280 {
42144281 id: "snooze",
42154282 label: `Snooze (${count})`,
4283 disabled: !canOperateThreads(selectedThreads),
42164284 children: [
42174285 ...snoozePresets.map((preset) => ({
42184286 id: `snooze:${preset.id}`,
42194287 label: `${preset.label} (${preset.whenLabel})`,
4288 disabled: !canOperateThreads(selectedThreads),
42204289 })),
4221 { id: "snooze:custom", label: "Custom…", separatorBefore: true },
4290 {
4291 id: "snooze:custom",
4292 label: "Custom…",
4293 separatorBefore: true,
4294 disabled: !canOperateThreads(selectedThreads),
4295 },
42224296 ],
42234297 },
42244298 ]
42254299 : []),
4226 ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []),
4300 ...(titleRegenerationMenuItem
4301 ? [
4302 {
4303 ...titleRegenerationMenuItem,
4304 disabled:
4305 titleRegenerationMenuItem.disabled ||
4306 !canOperateThreads(regeneratableTitleThreads),
4307 },
4308 ]
4309 : []),
42274310 { 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 },
42294317 ],
42304318 position,
42314319 ),
42324320 );
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;
42344337 if (clicked.value?.startsWith("snooze:")) {
42354338 const preset =
42364339 clicked.value === "snooze:custom"
......@@ -4303,7 +4406,11 @@ export default function Sidebar() {
43034406 return;
43044407 }
43054408 if (clicked.value === "settle") {
4306 settleThreads(threadKeys);
4409 settleThreads(
4410 actionTargets.map((thread) =>
4411 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
4412 ),
4413 );
43074414 clearSelection();
43084415 return;
43094416 }
......@@ -4328,6 +4435,15 @@ export default function Sidebar() {
43284435 );
43294436 if (confirmed._tag === "Failure" || !confirmed.value) return;
43304437 }
4438 if (
4439 !checkThreadOperations(
4440 threadKeys.flatMap((threadKey) => {
4441 const thread = threadByKeyRef.current.get(threadKey);
4442 return thread ? [thread] : [];
4443 }),
4444 )
4445 )
4446 return;
43314447 const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({
43324448 entries: threadKeys.map((threadKey) => ({ threadKey })),
43334449 delete: async ({ threadKey }, deletedThreadKeys) => {
......@@ -4469,6 +4585,10 @@ export default function Sidebar() {
44694585 const clicked = await settlePromise(() =>
44704586 api.contextMenu.show(
44714587 buildThreadActionMenuItems({
4588 canOperate: readEnvironmentScope(
4589 threadRef.environmentId,
4590 AuthOrchestrationOperateScope,
4591 ),
44724592 branch: thread.branch ?? null,
44734593 projectFilter: threadProjectGroup
44744594 ? {
......@@ -4495,7 +4615,8 @@ export default function Sidebar() {
44954615 position,
44964616 ),
44974617 );
4498 if (clicked._tag === "Failure") return;
4618 if (clicked._tag === "Failure" || clicked.value === null) return;
4619 if (threadActionRequiresOperate(clicked.value) && !checkThreadOperations([thread])) return;
44994620 if (clicked.value?.startsWith("snooze:")) {
45004621 const preset =
45014622 clicked.value === "snooze:custom"
......@@ -5228,6 +5349,7 @@ export default function Sidebar() {
52285349 key={threadKey}
52295350 id={threadKey}
52305351 contextDrag={isContextDrag}
5352 environmentId={thread.environmentId}
52315353 disabled={
52325354 renamingThreadKey === threadKey ||
52335355 section === "working" ||
apps/web/src/components/chat/ChatHeader.tsx+30-7
......@@ -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";
26import { scopeThreadRef } from "@t3tools/client-runtime/environment";
37import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
48import {
......@@ -23,6 +27,8 @@ import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";
2327import { readLocalApi } from "~/localApi";
2428import { threadEnvironment } from "../../state/threads";
2529import { useAtomCommand } from "../../state/use-atom-command";
30import { useOrchestrationCommand } from "../../state/use-orchestration-command";
31import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
2632import { ProjectFavicon } from "../ProjectFavicon";
2733import {
2834 WorkspaceBreadcrumb,
......@@ -81,7 +87,11 @@ export const ChatHeader = memo(function ChatHeader({
8187 () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId),
8288 [activeThreadEnvironmentId, activeThreadId],
8389 );
84 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
90 const canOperateThread = useEnvironmentScope(
91 activeThreadEnvironmentId,
92 AuthOrchestrationOperateScope,
93 );
94 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
8595 reportFailure: false,
8696 });
8797 // Inline rename, keyed by thread: navigating away drops an in-progress
......@@ -94,23 +104,36 @@ export const ChatHeader = memo(function ChatHeader({
94104 } | null>(null);
95105 if (
96106 renaming !== null &&
97 (renaming.threadId !== activeThreadId || renaming.environmentId !== activeThreadEnvironmentId)
107 (renaming.threadId !== activeThreadId ||
108 renaming.environmentId !== activeThreadEnvironmentId ||
109 !canOperateThread)
98110 ) {
99111 setRenaming(null);
100112 }
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;
102119 const renameCommittedRef = useRef(false);
103120 const startRename = useCallback(() => {
121 if (
122 !isServerThread ||
123 !readEnvironmentScope(activeThreadEnvironmentId, AuthOrchestrationOperateScope)
124 )
125 return;
104126 renameCommittedRef.current = false;
105127 setRenaming({
106 threadId: activeThreadId,
107128 environmentId: activeThreadEnvironmentId,
129 threadId: activeThreadId,
108130 title: activeThreadTitle,
109131 });
110 }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle]);
132 }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, isServerThread]);
111133 const commitRename = useCallback(
112134 (title: string) => {
113135 setRenaming(null);
136 if (!readEnvironmentScope(activeThreadEnvironmentId, AuthOrchestrationOperateScope)) return;
114137 const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle });
115138 if (resolution.action === "reject-empty") {
116139 toastManager.add({ type: "warning", title: "Thread title cannot be empty" });
......@@ -303,7 +326,7 @@ export const ChatHeader = memo(function ChatHeader({
303326 aria-label={`Thread actions for ${activeThreadTitle}`}
304327 aria-haspopup="menu"
305328 onClick={openMenuFromTitle}
306 onDoubleClick={handleTitleDoubleClick}
329 onDoubleClick={canOperateThread ? handleTitleDoubleClick : undefined}
307330 onBlur={cancelPendingTitleMenu}
308331 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"
309332 />
apps/web/src/components/pullRequest/PullRequestCommentForm.tsx+4-2
......@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
12/**
23 * The comment half of the floating composer: a remark on the pull request itself, optionally
34 * 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
67import { SendIcon } from "lucide-react";
78import { useState, type RefObject } from "react";
89
9import { useAtomCommand } from "~/state/use-atom-command";
1010import { pullRequestEnvironment } from "~/state/pullRequests";
1111
1212import { Button } from "../ui/button";
......@@ -38,7 +38,9 @@ export function PullRequestCommentForm({
3838}) {
3939 const [body, setBody] = useState("");
4040 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 });
4244 const followUpAction =
4345 detail.state === "open" &&
4446 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(() =>
2424 </>
2525 ),
2626}));
27vi.mock("~/state/session", async (original) => ({
28 ...(await original<typeof import("~/state/session")>()),
29 useEnvironmentScope: () => true,
30 readEnvironmentScope: () => true,
31}));
2732vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));
2833vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} }));
2934vi.mock("~/state/entities", () => ({ useProjects: () => [], useServerConfigs: () => new Map() }));
......@@ -39,7 +44,7 @@ vi.mock("~/hooks/useSettings", () => ({
3944vi.mock("~/hooks/useLiveRefresh", () => ({ useLiveRefresh: () => {} }));
4045vi.mock("~/hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => newThread }));
4146vi.mock("~/lib/sourceControlActions", () => ({
42 usePreparePullRequestThreadAction: () => ({ run: prepareThread }),
47 usePreparePullRequestThreadAction: () => ({ run: prepareThread, isAllowed: true, error: null }),
4348}));
4449vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
4550vi.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";
55import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
66import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
77import {
8 AuthOrchestrationOperateScope,
9 AuthSourceControlWriteScope,
810 type EnvironmentId,
911 type PullRequestAction,
1012 type PullRequestMergeMethod,
......@@ -70,6 +72,7 @@ import type { ReviewCommentContext } from "~/reviewCommentContext";
7072import { useProjects, useServerConfigs } from "~/state/entities";
7173import { useEnvironments } from "~/state/environments";
7274import { useEnvironmentQuery } from "~/state/query";
75import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
7376import { useLiveRefresh } from "~/hooks/useLiveRefresh";
7477import {
7578 pullRequestEnvironment,
......@@ -578,6 +581,7 @@ export function PullRequestDetailPanel({
578581 // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself
579582 // alone. One at a time whatever the key: they all check the same pull request out.
580583 const [handoff, setHandoff] = useState<string | null>(null);
584 const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope);
581585 const detailQuery = useEnvironmentQuery(
582586 pullRequestEnvironment.detail({ environmentId, input: reference }),
583587 );
......@@ -690,6 +694,25 @@ export function PullRequestDetailPanel({
690694 ? null
691695 : {
692696 ...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 },
693716 author: activity?.author ?? coreDetail.author,
694717 reviewers: activity?.reviewers ?? coreDetail.reviewers,
695718 comments: activity?.comments ?? [],
......@@ -699,7 +722,7 @@ export function PullRequestDetailPanel({
699722 commits: activity?.commits ?? [],
700723 reactions: activity?.reactions ?? [],
701724 },
702 [activity, coreDetail],
725 [activity, canWriteSourceControl, coreDetail],
703726 );
704727 const handoffSummary = detail ?? sharedSummary;
705728 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
......@@ -857,8 +880,12 @@ export function PullRequestDetailPanel({
857880 appliedForcedToken.current = forcedRefreshToken;
858881 void refreshFromHost();
859882 }, [forcedRefreshToken, refreshFromHost]);
860 const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });
861 const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false });
883 const runAction = useAtomCommand(pullRequestEnvironment.runAction, {
884 reportFailure: false,
885 });
886 const postComment = useAtomCommand(pullRequestEnvironment.comment, {
887 reportFailure: false,
888 });
862889 // Which action is in flight, not merely that one is: every control here is disabled while any
863890 // of them runs, but only the button that was pressed may say what it is doing.
864891 const [pendingAction, setPendingAction] = useState<PullRequestAction | null>(null);
......@@ -912,6 +939,7 @@ export function PullRequestDetailPanel({
912939 const actingEnvironmentId = acting?.environmentId ?? environmentId;
913940 const checkoutRoot =
914941 acting?.workspaceRoot ?? detail?.workspaceRoot ?? project?.workspaceRoot ?? null;
942 const canOperateThread = useEnvironmentScope(actingEnvironmentId, AuthOrchestrationOperateScope);
915943 const prepareThread = usePreparePullRequestThreadAction({
916944 environmentId: actingEnvironmentId,
917945 cwd: checkoutRoot,
......@@ -973,13 +1001,13 @@ export function PullRequestDetailPanel({
9731001 method?: PullRequestMergeMethod,
9741002 updateMethod?: PullRequestUpdateMethod,
9751003 ) => {
976 if (pendingAction !== null) return false;
1004 if (!canWriteSourceControl || pendingAction !== null) return false;
9771005 setPendingAction(action);
9781006 return finishAction(action, method, updateMethod);
9791007 };
9801008
9811009 const performCommentAction = async (body: string, action: "close" | "reopen") => {
982 if (pendingAction !== null) return { commentPosted: false };
1010 if (!canWriteSourceControl || pendingAction !== null) return { commentPosted: false };
9831011 setPendingAction(action);
9841012 const commentResult = await postComment({
9851013 environmentId,
......@@ -999,7 +1027,7 @@ export function PullRequestDetailPanel({
9991027
10001028 const saveTitle = async (next: string) => {
10011029 const title = next.trim();
1002 if (detail === null || titleSaving) return;
1030 if (!canWriteSourceControl || detail === null || titleSaving) return;
10031031 if (title.length === 0 || title === detail.title) {
10041032 setTitleScope(null);
10051033 return;
......@@ -1030,6 +1058,8 @@ export function PullRequestDetailPanel({
10301058 };
10311059
10321060 const attachTarget = composerDraftTarget ?? null;
1061 const canPrepareWorktree = prepareThread.isAllowed && canOperateThread;
1062 const canFixFindings = attachTarget !== null || canPrepareWorktree;
10331063 const handoffLabels = pullRequestHandoffLabels(attachTarget !== null);
10341064
10351065 const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => {
......@@ -1158,6 +1188,18 @@ export function PullRequestDetailPanel({
11581188 return;
11591189 }
11601190 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 }
11611203 setHandoff(kind);
11621204 // The menu closes on the press and takes its "Preparing..." label with it, so this is the
11631205 // only thing answering for the checkout. It carries no timeout of its own: a loading toast
......@@ -1528,7 +1570,7 @@ export function PullRequestDetailPanel({
15281570 <TooltipPopup>Check out this pull request</TooltipPopup>
15291571 </Tooltip>
15301572 <MenuPopup align="end" side="bottom">
1531 <MenuItem onClick={() => startCheckout("worktree")}>
1573 <MenuItem disabled={!canPrepareWorktree} onClick={() => startCheckout("worktree")}>
15321574 <GitBranchIcon className="mt-1 size-3.5 shrink-0 self-start" />
15331575 <span className="flex min-w-0 flex-col">
15341576 <span>In a separate worktree</span>
......@@ -1537,7 +1579,7 @@ export function PullRequestDetailPanel({
15371579 </span>
15381580 </span>
15391581 </MenuItem>
1540 <MenuItem onClick={() => startCheckout("local")}>
1582 <MenuItem disabled={!prepareThread.isAllowed} onClick={() => startCheckout("local")}>
15411583 <FolderGit2Icon className="mt-1 size-3.5 shrink-0 self-start" />
15421584 <span className="flex min-w-0 flex-col">
15431585 <span>In this repository</span>
......@@ -2016,7 +2058,10 @@ export function PullRequestDetailPanel({
20162058 </span>
20172059 </span>
20182060 </MenuItem>
2019 <MenuItem disabled={handoff !== null} onClick={startFixFindings}>
2061 <MenuItem
2062 disabled={handoff !== null || !canFixFindings}
2063 onClick={startFixFindings}
2064 >
20202065 <HammerIcon className="size-3.5" />
20212066 {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings}
20222067 </MenuItem>
......@@ -2383,7 +2428,9 @@ export function PullRequestDetailPanel({
23832428 <Button
23842429 size="xs"
23852430 variant="outline"
2386 disabled={titleSaving || titleDraft.trim().length === 0}
2431 disabled={
2432 !canWriteSourceControl || titleSaving || titleDraft.trim().length === 0
2433 }
23872434 onClick={() => void saveTitle(titleDraft)}
23882435 >
23892436 {titleSaving ? "Saving..." : "Save"}
......@@ -2706,7 +2753,7 @@ export function PullRequestDetailPanel({
27062753 pendingFinding={handoff}
27072754 fixFindingLabel={handoffLabels.fixFinding}
27082755 fixCheckLabel={handoffLabels.fixCheck}
2709 onFixFinding={startFixFinding}
2756 {...(canFixFindings ? { onFixFinding: startFixFinding } : {})}
27102757 onRefresh={refreshDetail}
27112758 onRefreshChecks={refreshFromHost}
27122759 />
......@@ -2746,7 +2793,7 @@ export function PullRequestDetailPanel({
27462793 onSelectedCommitChange={selectCodeCommit}
27472794 pendingFinding={handoff}
27482795 fixFindingLabel={handoffLabels.fixFinding}
2749 onFixFinding={startFixFinding}
2796 {...(canFixFindings ? { onFixFinding: startFixFinding } : {})}
27502797 onRefresh={refreshDetail}
27512798 refreshToken={codeRefreshToken}
27522799 />
apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx+4-2
......@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
12/**
23 * Putting a label on, and taking one off, from the row that says which it already wears.
34 *
......@@ -11,7 +12,6 @@ import { useMemo, useState } from "react";
1112
1213import { pullRequestEnvironment } from "~/state/pullRequests";
1314import { useEnvironmentQuery } from "~/state/query";
14import { useAtomCommand } from "~/state/use-atom-command";
1515import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
1616
1717import { toastManager } from "../ui/toast";
......@@ -48,7 +48,9 @@ export function PullRequestLabelPicker({
4848 const candidatesQuery = useEnvironmentQuery(
4949 open ? pullRequestEnvironment.labelCandidates({ environmentId, input: reference }) : null,
5050 );
51 const setLabels = useAtomCommand(pullRequestEnvironment.setLabels, { reportFailure: false });
51 const setLabels = useAtomCommand(pullRequestEnvironment.setLabels, {
52 reportFailure: false,
53 });
5254
5355 const candidates = useMemo(
5456 () => (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 @@
11import { useState } from "react";
2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
2import {
3 AuthSourceControlWriteScope,
4 type EnvironmentId,
5 type ScopedThreadRef,
6} from "@t3tools/contracts";
37
48import { cn } from "~/lib/utils";
9import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
510
611import { Button } from "../ui/button";
712import { Textarea } from "../ui/textarea";
......@@ -43,6 +48,7 @@ export function PullRequestMarkdownEditor({
4348 readonly onSave: (next: string) => void;
4449 readonly onCancel: () => void;
4550}) {
51 const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope);
4652 const [draft, setDraft] = useState(value);
4753 const [preview, setPreview] = useState(false);
4854 // The words this draft started from. React keeps a component instance wherever the same
......@@ -55,7 +61,7 @@ export function PullRequestMarkdownEditor({
5561 setDraft(value);
5662 }
5763 const empty = draft.trim().length === 0;
58 const saveDisabled = saving || (empty && !allowEmpty);
64 const saveDisabled = !canWriteSourceControl || saving || (empty && !allowEmpty);
5965
6066 return (
6167 <div
......@@ -70,7 +76,12 @@ export function PullRequestMarkdownEditor({
7076 ) {
7177 event.preventDefault();
7278 event.stopPropagation();
73 if (!saveDisabled && !event.repeat) onSave(draft);
79 if (
80 !saveDisabled &&
81 !event.repeat &&
82 readEnvironmentScope(environmentId, AuthSourceControlWriteScope)
83 )
84 onSave(draft);
7485 return;
7586 }
7687 if (event.key !== "Escape" || saving) return;
......@@ -119,7 +130,20 @@ export function PullRequestMarkdownEditor({
119130 <Button size="xs" variant="ghost" disabled={saving} onClick={onCancel}>
120131 Cancel
121132 </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 >
123147 {saving ? "Saving..." : "Save"}
124148 </Button>
125149 </div>
apps/web/src/components/pullRequest/PullRequestReactions.tsx+4-2
......@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
12import type {
23 EnvironmentId,
34 PullRequestReaction,
......@@ -9,7 +10,6 @@ import { useState } from "react";
910
1011import { cn } from "~/lib/utils";
1112import { pullRequestEnvironment } from "~/state/pullRequests";
12import { useAtomCommand } from "~/state/use-atom-command";
1313
1414import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
1515import { toastManager } from "../ui/toast";
......@@ -58,7 +58,9 @@ export function PullRequestReactionBar({
5858 readonly signature: string;
5959 readonly values: ReadonlyMap<PullRequestReactionContent, boolean>;
6060 }>({ signature: "", values: EMPTY_PENDING });
61 const setReaction = useAtomCommand(pullRequestEnvironment.setReaction, { reportFailure: false });
61 const setReaction = useAtomCommand(pullRequestEnvironment.setReaction, {
62 reportFailure: false,
63 });
6264
6365 const signature = reactionsSignature(reactions);
6466 const values = pending.signature === signature ? pending.values : EMPTY_PENDING;
apps/web/src/components/pullRequest/PullRequestReviewForm.tsx+1-1
......@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
12/**
23 * The review half of the floating composer: the summary and the verdict that sends it, together
34 * 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";
910import { useState, type ReactNode, type RefObject } from "react";
1011
1112import { pullRequestEnvironment } from "~/state/pullRequests";
12import { useAtomCommand } from "~/state/use-atom-command";
1313
1414import { Button } from "../ui/button";
1515import { Select, SelectItem, SelectPopup, SelectTrigger } from "../ui/select";
apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx+1-1
......@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
12/**
23 * Asking someone to review, from the row that says who is already reviewing.
34 *
......@@ -15,7 +16,6 @@ import { useMemo, useState } from "react";
1516
1617import { pullRequestEnvironment } from "~/state/pullRequests";
1718import { useEnvironmentQuery } from "~/state/query";
18import { useAtomCommand } from "~/state/use-atom-command";
1919import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
2020
2121import { toastManager } from "../ui/toast";
apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx+6-1
......@@ -1,4 +1,6 @@
1import { useAtomValue } from "@effect/atom-react";
12import type { PullRequestAction } from "@t3tools/contracts";
3import { pullRequestEnvironment } from "~/state/pullRequests";
24import { useUiStateStore } from "~/uiStateStore";
35import { Button } from "../ui/button";
46import { Spinner } from "../ui/spinner";
......@@ -37,6 +39,9 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr
3739 sweeping?: boolean;
3840 onCloseSweepStart?: (entry: Entry, event: PointerEvent) => void;
3941}) {
42 const canWrite = useAtomValue(
43 pullRequestEnvironment.runAction.permissionAtom(entry.environmentId),
44 );
4045 const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver(
4146 entry.environmentId,
4247 entry.projectId,
......@@ -90,7 +95,7 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr
9095 <Button
9196 variant={action === "close" ? "destructive-outline" : "outline"}
9297 size="xs"
93 disabled={busy || (action === "merge" && entry.stack !== undefined)}
98 disabled={!canWrite || busy || (action === "merge" && entry.stack !== undefined)}
9499 aria-label={`${label} #${entry.number}`}
95100 onClick={() => void perform(action)}
96101 onPointerDown={(event) => {
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+1-1
......@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
12import type {
23 EnvironmentId,
34 PullRequestComment,
......@@ -16,7 +17,6 @@ import {
1617} from "lucide-react";
1718import { useRef, useState, type ReactNode } from "react";
1819
19import { useAtomCommand } from "~/state/use-atom-command";
2020import { pullRequestEnvironment } from "~/state/pullRequests";
2121import { cn } from "~/lib/utils";
2222import { useOpenLink } from "~/browser/useOpenLink";
apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx+1-1
......@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
12import type {
23 EnvironmentId,
34 PullRequestActor,
......@@ -18,7 +19,6 @@ import { useState, type ReactNode } from "react";
1819import { cn } from "~/lib/utils";
1920import { readLocalApi } from "~/localApi";
2021import { pullRequestEnvironment } from "~/state/pullRequests";
21import { useAtomCommand } from "~/state/use-atom-command";
2222import { formatRelativeTimeLabel } from "~/timestampFormat";
2323
2424import { Button } from "../ui/button";
apps/web/src/components/pullRequest/usePullRequestActions.ts+4-2
......@@ -1,3 +1,4 @@
1import { useAtomCommand } from "~/state/use-atom-command";
12/**
23 * The actions a pull request offers, extracted from the detail panel so smaller surfaces — the
34 * 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";
3132import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
3233import type { ReviewCommentContext } from "~/reviewCommentContext";
3334import { pullRequestEnvironment } from "~/state/pullRequests";
34import { useAtomCommand } from "~/state/use-atom-command";
3535
3636import { toastManager } from "../ui/toast";
3737import { handoffPrompt, handoffReviewComments, readableFailure } from "./pullRequestDetail.logic";
......@@ -140,7 +140,9 @@ export function usePullRequestActionRunner({
140140 /** Small surfaces resolve repository settings on the click, not for every visible row. */
141141 resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod;
142142}) {
143 const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });
143 const runAction = useAtomCommand(pullRequestEnvironment.runAction, {
144 reportFailure: false,
145 });
144146 const [actionPending, setActionPending] = useState(false);
145147 const pendingRef = useRef(false);
146148
apps/web/src/components/settings/ConnectionsSettings.tsx+6
......@@ -31,6 +31,7 @@ import {
3131 AuthRelayReadScope,
3232 AuthRelayWriteScope,
3333 AuthReviewWriteScope,
34 AuthSourceControlWriteScope,
3435 AuthStandardClientScopes,
3536 AuthTerminalOperateScope,
3637 type AuthClientSession,
......@@ -259,6 +260,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
259260 title: "Use terminals",
260261 description: "Create terminals and send input to running shells.",
261262 },
263 {
264 scope: AuthSourceControlWriteScope,
265 title: "Change source control",
266 description: "Commit, push, manage branches and repositories, and change pull requests.",
267 },
262268 {
263269 scope: AuthReviewWriteScope,
264270 title: "Write reviews",
apps/web/src/components/threadActionMenu.logic.test.ts+52
......@@ -7,6 +7,7 @@ import {
77} from "./threadActionMenu.logic";
88
99const baseState: ThreadActionMenuState = {
10 canOperate: true,
1011 branch: null,
1112 projectFilter: null,
1213 isPinned: false,
......@@ -39,6 +40,57 @@ function allIds(state: ThreadActionMenuState): string[] {
3940}
4041
4142describe("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
4294 it("hides lifecycle items when the environment lacks the capabilities", () => {
4395 expect(
4496 ids({
apps/web/src/components/threadActionMenu.logic.ts+28-1
......@@ -70,6 +70,7 @@ export function buildDraftActionMenuItems(options: {
7070}
7171
7272export interface ThreadActionMenuState {
73 readonly canOperate: boolean;
7374 readonly branch: string | null;
7475 /**
7576 * Project scoping for the thread list. Null on surfaces with no scoped
......@@ -100,6 +101,19 @@ export interface ThreadActionMenuState {
100101 readonly snoozePresets: ReadonlyArray<SnoozePreset>;
101102}
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
103117/**
104118 * Single source for the per-thread action menu: the sidebar row's right-click
105119 * menu and the chat header menu share labels, ordering, and capability gating.
......@@ -108,7 +122,7 @@ export interface ThreadActionMenuState {
108122export function buildThreadActionMenuItems(
109123 state: ThreadActionMenuState,
110124): ReadonlyArray<ContextMenuItem<ThreadActionMenuId>> {
111 return [
125 const items: ReadonlyArray<ContextMenuItem<ThreadActionMenuId>> = [
112126 ...(state.branch
113127 ? [
114128 {
......@@ -235,4 +249,17 @@ export function buildThreadActionMenuItems(
235249 icon: "trash",
236250 },
237251 ];
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 );
238265}
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 {
77 squashAtomCommandFailure,
88} from "@t3tools/client-runtime/state/runtime";
99import { 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";
1115import { useRouter } from "@tanstack/react-router";
1216import { useCallback, useMemo } from "react";
1317
1418import { resolveSnoozePresets } from "../components/Sidebar.snooze";
1519import {
1620 buildThreadActionMenuItems,
21 threadActionRequiresOperate,
1722 type ThreadActionMenuId,
1823} from "../components/threadActionMenu.logic";
1924import { stackedThreadToast, toastManager } from "../components/ui/toast";
2025import { threadEnvironment } from "../state/threads";
21import { useAtomCommand } from "../state/use-atom-command";
26import { useOrchestrationCommand } from "../state/use-orchestration-command";
27import { readEnvironmentScope } from "../state/session";
2228import {
2329 readEnvironmentSupportsAutoSettleOptOut,
2430 readEnvironmentSupportsPinning,
......@@ -94,7 +100,7 @@ export function useThreadActionMenu(input: {
94100 deleteThread,
95101 markThreadUnread,
96102 } = useThreadActions();
97 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
103 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
98104 reportFailure: false,
99105 });
100106 const handleNewThread = useNewThreadHandler();
......@@ -142,6 +148,7 @@ export function useThreadActionMenu(input: {
142148 const isRegeneratingTitle = thread.titleRegeneration != null;
143149 const snoozePresets = resolveSnoozePresets(now, timestampFormat);
144150 const items = buildThreadActionMenuItems({
151 canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope),
145152 branch: thread.branch ?? null,
146153 projectFilter: null,
147154 isPinned: thread.pinnedAt != null,
......@@ -157,6 +164,16 @@ export function useThreadActionMenu(input: {
157164 const clicked = await settlePromise(() => api.contextMenu.show(items, position));
158165 if (clicked._tag === "Failure" || clicked.value === null) return;
159166 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 }
160177 if (action.startsWith("snooze:")) {
161178 const preset =
162179 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 {
77import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
88import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
99import { 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";
1118import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
1219import * as Cause from "effect/Cause";
1320import * as Schema from "effect/Schema";
......@@ -17,6 +24,7 @@ import { useCallback, useMemo, useRef } from "react";
1724
1825import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";
1926import { useComposerDraftStore } from "../composerDraftStore";
27import { environmentSession, readEnvironmentScope } from "../state/session";
2028import { terminalEnvironment } from "../state/terminal";
2129import { appAtomRegistry } from "../rpc/atomRegistry";
2230import { environmentServerConfigsAtom } from "../state/server";
......@@ -49,6 +57,8 @@ import { useClientSettings } from "./useSettings";
4957import * as ThreadUndo from "./threadUndo";
5058import { showThreadUndoNotice } from "./showThreadUndoNotice";
5159import { useAtomCommand } from "../state/use-atom-command";
60import { useOrchestrationCommand } from "../state/use-orchestration-command";
61import { useAtomQueryRunner } from "../state/use-atom-query-runner";
5262
5363export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()(
5464 "ThreadArchiveBlockedError",
......@@ -243,49 +253,64 @@ export function useAcknowledgeThreadWoke() {
243253 );
244254}
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
246269export function useThreadActions() {
247 const closeTerminal = useAtomCommand(terminalEnvironment.close);
248 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
270 const archiveThreadMutation = useOrchestrationCommand(threadEnvironment.archive, {
249271 reportFailure: false,
250272 });
251 const unarchiveThreadMutation = useAtomCommand(threadEnvironment.unarchive, {
273 const unarchiveThreadMutation = useOrchestrationCommand(threadEnvironment.unarchive, {
252274 reportFailure: false,
253275 });
254 const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, {
276 const deleteThreadMutation = useOrchestrationCommand(threadEnvironment.delete, {
255277 reportFailure: false,
256278 });
257 const settleThreadMutation = useAtomCommand(threadEnvironment.settle, {
279 const settleThreadMutation = useOrchestrationCommand(threadEnvironment.settle, {
258280 reportFailure: false,
259281 });
260 const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, {
282 const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, {
261283 reportFailure: false,
262284 });
263 const pinThreadMutation = useAtomCommand(threadEnvironment.pin, {
285 const pinThreadMutation = useOrchestrationCommand(threadEnvironment.pin, {
264286 reportFailure: false,
265287 });
266 const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, {
288 const unpinThreadMutation = useOrchestrationCommand(threadEnvironment.unpin, {
267289 reportFailure: false,
268290 });
269 const setThreadAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, {
291 const setThreadAutoSettleMutation = useOrchestrationCommand(threadEnvironment.setAutoSettle, {
270292 reportFailure: false,
271293 });
272 const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, {
294 const reorderPinnedThreadMutation = useOrchestrationCommand(threadEnvironment.reorderPin, {
273295 reportFailure: false,
274296 });
275 const reorderActiveThreadMutation = useAtomCommand(threadEnvironment.reorderActive, {
297 const reorderActiveThreadMutation = useOrchestrationCommand(threadEnvironment.reorderActive, {
276298 reportFailure: false,
277299 });
278 const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, {
300 const snoozeThreadMutation = useOrchestrationCommand(threadEnvironment.snooze, {
279301 reportFailure: false,
280302 });
281 const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, {
303 const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, {
282304 reportFailure: false,
283305 });
284306 const markThreadUnread = useMarkThreadUnread();
285 const stopThreadSession = useAtomCommand(threadEnvironment.stopSession);
307 const stopThreadSession = useOrchestrationCommand(threadEnvironment.stopSession);
286308 const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, {
287309 reportFailure: false,
288310 });
311 const loadSessionState = useAtomQueryRunner(environmentSession.sessionStateAtom, {
312 reportFailure: false,
313 });
289314 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, {
290315 reportFailure: false,
291316 });
......@@ -412,6 +437,8 @@ export function useThreadActions() {
412437
413438 const deleteThread = useCallback(
414439 async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet<string> } = {}) => {
440 const permissionFailure = threadOperationFailure(target);
441 if (permissionFailure) return permissionFailure;
415442 const resolved = resolveThreadTarget(target);
416443 if (!resolved) {
417444 // Thread not in main store (e.g. archived thread) — dispatch delete directly.
......@@ -456,13 +483,22 @@ export function useThreadActions() {
456483 const environmentConfig = appAtomRegistry
457484 .get(environmentServerConfigsAtom)
458485 .get(threadRef.environmentId);
459 // A Scratch thread's folder is not a git worktree, and deleting the
460 // thread keeps its files.
461 const canDeleteWorktree =
486 const localApi = readLocalApi();
487 let canDeleteWorktree = false;
488 if (
462489 orphanedWorktreePath !== null &&
463490 threadProject !== null &&
464 !isScratchProject(threadProject, environmentConfig?.scratchWorkspaceRoot);
465 const localApi = readLocalApi();
491 !isScratchProject(threadProject, environmentConfig?.scratchWorkspaceRoot) &&
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 }
466502 let shouldDeleteWorktree = false;
467503 const environmentSettings = environmentConfig?.settings;
468504 const automaticWorktreeCleanup = environmentSettings
......@@ -493,11 +529,6 @@ export function useThreadActions() {
493529 });
494530 }
495531
496 await closeTerminal({
497 environmentId: threadRef.environmentId,
498 input: { threadId: threadRef.threadId, deleteHistory: true },
499 });
500
501532 const deletedThreadIds = deletedIds ?? new Set<ThreadId>();
502533 const currentRouteThreadRef = getCurrentRouteThreadRef();
503534 const shouldNavigateToFallback =
......@@ -546,14 +577,26 @@ export function useThreadActions() {
546577 return deleteResult;
547578 }
548579
549 const removeResult = await removeWorktree({
550 environmentId: threadRef.environmentId,
551 input: {
552 cwd: threadProject.workspaceRoot,
553 path: orphanedWorktreePath,
554 force: true,
555 },
556 });
580 const removeResult = readEnvironmentScope(
581 threadRef.environmentId,
582 AuthSourceControlWriteScope,
583 )
584 ? await removeWorktree({
585 environmentId: threadRef.environmentId,
586 input: {
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 );
557600 const refreshResult =
558601 removeResult._tag === "Success"
559602 ? await refreshVcsStatus({
......@@ -597,9 +640,9 @@ export function useThreadActions() {
597640 clearComposerDraftForThread,
598641 clearProjectDraftThreadById,
599642 clearTerminalUiState,
600 closeTerminal,
601643 deleteThreadMutation,
602644 getCurrentRouteThreadRef,
645 loadSessionState,
603646 refreshVcsStatus,
604647 removeWorktree,
605648 router,
......@@ -797,6 +840,8 @@ export function useThreadActions() {
797840
798841 const confirmAndUnpinThread = useCallback(
799842 async (target: ScopedThreadRef) => {
843 const permissionFailure = threadOperationFailure(target);
844 if (permissionFailure) return permissionFailure;
800845 const localApi = readLocalApi();
801846 const resolved = resolveThreadTarget(target);
802847 const confirmationResult = await requestThreadUnpinConfirmation({
......@@ -930,6 +975,8 @@ export function useThreadActions() {
930975
931976 const confirmAndDeleteThread = useCallback(
932977 async (target: ScopedThreadRef) => {
978 const permissionFailure = threadOperationFailure(target);
979 if (permissionFailure) return permissionFailure;
933980 const localApi = readLocalApi();
934981 const resolved = resolveThreadTarget(target);
935982
apps/web/src/hooks/useThreadActions.undo.test.ts+5
......@@ -20,6 +20,11 @@ const router = vi.hoisted(() => ({
2020 navigate: vi.fn(async () => {}),
2121 state: { matches: [{ params: {} as Record<string, string> }] },
2222}));
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}));
2328vi.mock("react", async (original) => ({
2429 ...(await original<typeof import("react")>()),
2530 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 {
99 type VcsActionOperation,
1010 type RunVcsStackedActionInput,
1111} from "@t3tools/client-runtime/state/vcs";
12import type {
13 EnvironmentId,
14 GitResolvePullRequestResult,
15 SourceControlCloneProtocol,
16 SourceControlRepositoryVisibility,
17 ThreadId,
12import {
13 AuthOrchestrationOperateScope,
14 AuthSourceControlWriteScope,
15 EnvironmentAuthorizationError,
16 type EnvironmentId,
17 type GitResolvePullRequestResult,
18 type SourceControlCloneProtocol,
19 type SourceControlRepositoryVisibility,
20 type ThreadId,
1821} from "@t3tools/contracts";
1922import * as Cause from "effect/Cause";
2023import * as Option from "effect/Option";
......@@ -24,6 +27,7 @@ import { useCallback } from "react";
2427import { appAtomRegistry } from "../rpc/atomRegistry";
2528import { gitEnvironment } from "./git";
2629import { useEnvironmentQuery } from "./query";
30import { readEnvironmentScope, useEnvironmentScope } from "./session";
2731import { sourceControlEnvironment } from "./sourceControl";
2832import { useAtomCommand } from "./use-atom-command";
2933import { vcsActionManager, vcsEnvironment } from "./vcs";
......@@ -45,11 +49,15 @@ interface SourceControlActionState<
4549 R extends AtomCommandResult<unknown, unknown>,
4650> {
4751 readonly isPending: boolean;
52 readonly isAllowed: boolean;
4853 readonly error: unknown;
4954 readonly run: (
5055 ...args: TArgs
5156 ) => Promise<
52 AtomCommandResult<AtomCommandSuccess<R>, AtomCommandFailure<R> | VcsActionUnavailableError>
57 AtomCommandResult<
58 AtomCommandSuccess<R>,
59 AtomCommandFailure<R> | VcsActionUnavailableError | EnvironmentAuthorizationError
60 >
5361 >;
5462 readonly resetError: () => void;
5563}
......@@ -73,6 +81,7 @@ function useAction<
7381 readonly onSuccess?: () => void;
7482 readonly managedExternally?: boolean;
7583}): SourceControlActionState<TArgs, R> {
84 const isAllowed = useEnvironmentScope(input.scope.environmentId, AuthSourceControlWriteScope);
7685 const operation = ACTION_OPERATION[input.kind];
7786 const state = useAtomValue(vcsActionManager.stateAtom(input.scope));
7887 const ownsState = state.operation === operation;
......@@ -83,6 +92,19 @@ function useAction<
8392
8493 const run = useCallback(
8594 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 }
86108 const execute = async (): Promise<
87109 AtomCommandResult<AtomCommandSuccess<R>, AtomCommandFailure<R>>
88110 > => {
......@@ -108,6 +130,7 @@ function useAction<
108130 );
109131
110132 return {
133 isAllowed,
111134 error: ownsState ? state.error : null,
112135 isPending: ownsState && state.isRunning,
113136 resetError,
......@@ -307,6 +330,20 @@ export function usePreparePullRequestThreadAction(scope: SourceControlActionScop
307330 ),
308331 );
309332 }
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 }
310347 return preparePullRequestThread({
311348 environmentId: target.environmentId,
312349 input: {
docs/user/remote-access.md+6
......@@ -281,6 +281,12 @@ Grouping checkouts does not combine their permissions. Shared project settings
281281require `orchestration:operate` on every member environment; actions on one
282282checkout 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
284290Settings changes, provider management, and environment maintenance can be granted
285291separately from access administration. New standard pairings include these
286292permissions. 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* <
203203 return yield* method(input).pipe(Effect.ensuring(completeObservation));
204204});
205205
206function runStreamGuarded<TTag extends EnvironmentStreamCommandRpcTag>(
206export function runStreamGuarded<TTag extends EnvironmentStreamCommandRpcTag>(
207207 tag: TTag,
208208 input: EnvironmentRpcInput<TTag>,
209209): Stream.Stream<
packages/client-runtime/src/state/commandPermissions.test.ts+43
......@@ -2,6 +2,8 @@ import { describe, expect, it } from "@effect/vitest";
22import { vi } from "vite-plus/test";
33import {
44 AuthOrchestrationOperateScope,
5 AuthSourceControlWriteScope,
6 ThreadId,
57 EnvironmentId,
68 ScheduledTaskId,
79 WS_METHODS,
......@@ -150,3 +152,44 @@ describe("command permissions", () => {
150152 }
151153 });
152154});
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 @@
1import { createCommandPermissions } from "./commandPermissions.ts";
2import { RpcPermissionGuard } from "../rpc/client.ts";
3import { vi } from "vite-plus/test";
14import {
25 EnvironmentId,
6 AuthSourceControlWriteScope,
37 ProjectId,
48 PullRequestOperationError,
59 WS_METHODS,
610 type PullRequestStack,
11 type AuthSessionState,
712} from "@t3tools/contracts";
813import { expect, it } from "@effect/vitest";
914import * as Data from "effect/Data";
......@@ -194,6 +199,7 @@ for (const scenario of [
194199 : route(WS_METHODS.pullRequestsRunAction, input)
195200 ).pipe(
196201 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
202 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
197203 Effect.provideService(GitHubRoutingPermissions, {
198204 ...trustedRouting,
199205 get: () => Effect.succeed(trusted ? "read-write" : "off"),
......@@ -301,6 +307,7 @@ it.effect("shares active read identity probes while keeping later reads and muta
301307 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
302308 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
303309 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
310 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
304311 );
305312 }),
306313 ),
......@@ -554,6 +561,7 @@ it.effect.each(["github", "gitlab", "bitbucket", "azure-devops"] as const)(
554561 }).pipe(
555562 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
556563 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
564 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
557565 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
558566 );
559567 const environment = provider === "github" ? "local" : "origin";
......@@ -720,10 +728,12 @@ it.effect.each(["default", "origin-off", "destination-off", "read-only"] as cons
720728 }).pipe(
721729 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
722730 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
731 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
723732 );
724733 yield* permission === "default"
725734 ? request
726735 : request.pipe(
736 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
727737 Effect.provideService(GitHubRoutingPermissions, {
728738 ...trustedRouting,
729739 get: (entry) =>
......@@ -842,6 +852,7 @@ it.effect.each(
842852 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
843853 // This also represents a user enabling the stale catalog entry after re-resolution.
844854 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
855 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
845856 );
846857 yield* stored === "unavailable"
847858 ? route
......@@ -897,6 +908,7 @@ it.live.each(["origin", "alternate"] as const)(
897908 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
898909 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
899910 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
911 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
900912 Effect.flip,
901913 );
902914 expect(error._tag).toBe("PullRequestOperationError");
......@@ -958,6 +970,7 @@ it.effect.each(
958970 }).pipe(
959971 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
960972 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
973 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
961974 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
962975 );
963976 const fiber = yield* request.pipe(Effect.forkChild);
......@@ -1014,6 +1027,7 @@ it.live("keeps source workspace metadata when an alternate answers a detail read
10141027 }).pipe(
10151028 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
10161029 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
1030 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
10171031 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
10181032 );
10191033 expect(result).toEqual({
......@@ -1101,6 +1115,7 @@ it.live(
11011115 }).pipe(
11021116 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
11031117 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
1118 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
11041119 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
11051120 );
11061121 }),
......@@ -1649,6 +1664,7 @@ it.effect.each([false, true])(
16491664 }).pipe(
16501665 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
16511666 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
1667 Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }),
16521668 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
16531669 );
16541670 expect(result).toEqual({ state: "open", checks: [] });
......@@ -1980,3 +1996,78 @@ it.effect("refreshes stack state after reopening and head SHAs after a turn", ()
19801996 }),
19811997 ),
19821998);
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";
1919
2020import {
2121 createAtomCommandScheduler,
22 createEnvironmentCommand,
2322 createEnvironmentRpcCommand,
2423 createEnvironmentRpcQueryAtomFamily,
2524 createEnvironmentRpcSubscriptionAtomFamily,
......@@ -320,7 +319,8 @@ export function createPullRequestEnvironmentAtoms<R, E>(
320319 JSON.stringify([environmentId, input.projectId, input.repository, input.number]),
321320 },
322321 }),
323 runAction: createEnvironmentCommand(runtime, {
322 runAction: createEnvironmentRpcCommand(runtime, {
323 tag: WS_METHODS.pullRequestsRunAction,
324324 label: "environment-data:pull-requests:run-action",
325325 // Preparation belongs to the write's lane. Refreshable queries would restart it after
326326 // every preceding action, and preparing outside the lane could reorder the clicks.
......@@ -328,8 +328,6 @@ export function createPullRequestEnvironmentAtoms<R, E>(
328328 input: PullRequestActionInput & {
329329 readonly resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod;
330330 },
331 registry,
332 environmentId,
333331 ) =>
334332 Effect.gen(function* () {
335333 const { resolveMergeMethod, ...actionInput } = input;
......@@ -369,9 +367,10 @@ export function createPullRequestEnvironmentAtoms<R, E>(
369367 });
370368 preparedInput = { ...actionInput, mergeMethod };
371369 }
372 yield* routedRequest(WS_METHODS.pullRequestsRunAction, preparedInput);
373 registry.refresh(preview({ environmentId, input: actionInput }));
370 return yield* routedRequest(WS_METHODS.pullRequestsRunAction, preparedInput);
374371 }),
372 onSuccess: ({ environmentId, input }, registry) =>
373 Effect.sync(() => registry.refresh(preview({ environmentId, input }))),
375374 scheduler: commandScheduler,
376375 concurrency: serialPerEnvironment,
377376 }),
packages/client-runtime/src/state/runtime.ts+12-7
......@@ -1,5 +1,5 @@
11import { createCommandPermissions } from "./commandPermissions.ts";
2import { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts";
2import { followStreamInEnvironment } from "./environmentStreams.ts";
33export { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts";
44import {
55 type ClientGuardedRpcTag,
......@@ -717,13 +717,18 @@ export function createEnvironmentRpcSubscriptionAtomFamily<
717717 });
718718}
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>(
721726 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, ER>,
722727 options: {
723728 readonly label: string;
724729 readonly tag: TTag;
725730 readonly execute?: (
726 input: EnvironmentRpcInput<TTag>,
731 input: Input,
727732 ) => Effect.Effect<
728733 EnvironmentRpcSuccess<TTag>,
729734 EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError,
......@@ -732,19 +737,19 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary
732737 readonly scheduler?: AtomCommandScheduler;
733738 readonly concurrency?: AtomCommandConcurrency<{
734739 readonly environmentId: EnvironmentIdType;
735 readonly input: EnvironmentRpcInput<TTag>;
740 readonly input: NoInfer<Input>;
736741 }>;
737742 readonly onSuccess?: (
738743 target: {
739744 readonly environmentId: EnvironmentIdType;
740 readonly input: EnvironmentRpcInput<TTag>;
745 readonly input: NoInfer<Input>;
741746 },
742747 registry: AtomRegistry.AtomRegistry,
743748 ) => Effect.Effect<void, never, R>;
744749 readonly onSettled?: (
745750 target: {
746751 readonly environmentId: EnvironmentIdType;
747 readonly input: EnvironmentRpcInput<TTag>;
752 readonly input: NoInfer<Input>;
748753 },
749754 registry: AtomRegistry.AtomRegistry,
750755 ) => Effect.Effect<void, never, R>;
......@@ -755,7 +760,7 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary
755760 label: options.label,
756761 ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }),
757762 ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }),
758 execute: (input: EnvironmentRpcInput<TTag>, registry, environmentId) => {
763 execute: (input: Input, registry, environmentId) => {
759764 const target = {
760765 environmentId,
761766 input,
packages/client-runtime/src/state/sourceControl.test.ts+22
......@@ -1,5 +1,7 @@
1import { vi } from "vite-plus/test";
12import {
23 EnvironmentId,
4 AuthSourceControlWriteScope,
35 WS_METHODS,
46 type SourceControlPublishRepositoryResult,
57} from "@t3tools/contracts";
......@@ -168,3 +170,23 @@ describe("source control environment atoms", () => {
168170 ),
169171 );
170172});
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 @@
1import { vi } from "vite-plus/test";
12import {
23 EnvironmentId,
4 AuthSourceControlWriteScope,
35 WS_METHODS,
46 type VcsListRefsInput,
57 type VcsListRefsResult,
......@@ -643,3 +645,23 @@ describe("cached VCS refs", () => {
643645 ),
644646 );
645647});
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 @@
1import { vi } from "vite-plus/test";
12import {
23 EnvironmentId,
4 AuthSourceControlWriteScope,
35 ThreadId,
46 ProjectId,
57 WS_METHODS,
......@@ -718,3 +720,23 @@ describe("vcsActionState", () => {
718720 ),
719721 );
720722});
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";
1919
2020import type { EnvironmentRegistry } from "../connection/registry.ts";
2121import * as Persistence from "../platform/persistence.ts";
22import { runStream } from "../rpc/client.ts";
22import { runStreamGuarded, RpcPermissionGuard } from "../rpc/client.ts";
2323import {
2424 createRuntimeCommand,
2525 runStreamInEnvironment,
2626 type AtomCommand,
2727 type AtomCommandResult,
2828} from "./runtime.ts";
29import { createCommandPermissions } from "./commandPermissions.ts";
2930import { vcsCommandScheduler } from "./vcsCommandScheduler.ts";
3031import { invalidateCachedVcsRefs } from "./vcsRefInvalidation.ts";
3132
......@@ -483,7 +484,7 @@ export function createVcsActionManager<R, E>(
483484 return consumeVcsActionProgress(
484485 runStreamInEnvironment(
485486 target.environmentId,
486 runStream(WS_METHODS.gitRunStackedAction, rpcInput),
487 runStreamGuarded(WS_METHODS.gitRunStackedAction, rpcInput),
487488 ),
488489 {
489490 target,
......@@ -507,6 +508,10 @@ export function createVcsActionManager<R, E>(
507508 }),
508509 },
509510 ).pipe(
511 Effect.provideService(RpcPermissionGuard, {
512 authorize: (id, method, payload) =>
513 createCommandPermissions(runtime, method).authorize(registry, id, payload),
514 }),
510515 Effect.ensuring(invalidateCachedVcsRefs(registry, target)),
511516 Effect.tap(() => clearOwnedState),
512517 Effect.tapError((error) =>
packages/contracts/src/auth.ts+3
......@@ -84,6 +84,7 @@ export const AuthSettingsWriteScope = "settings:write" as const;
8484export const AuthProvidersManageScope = "providers:manage" as const;
8585export const AuthEnvironmentMaintainScope = "environment:maintain" as const;
8686export const AuthTerminalOperateScope = "terminal:operate" as const;
87export const AuthSourceControlWriteScope = "source-control:write" as const;
8788export const AuthReviewWriteScope = "review:write" as const;
8889export const AuthAccessReadScope = "access:read" as const;
8990export const AuthAccessWriteScope = "access:write" as const;
......@@ -97,6 +98,7 @@ export const AuthEnvironmentScope = Schema.Literals([
9798 AuthEnvironmentMaintainScope,
9899 AuthTerminalOperateScope,
99100 AuthReviewWriteScope,
101 AuthSourceControlWriteScope,
100102 AuthAccessReadScope,
101103 AuthAccessWriteScope,
102104 AuthRelayReadScope,
......@@ -114,6 +116,7 @@ export const AuthStandardClientScopes = [
114116 AuthEnvironmentMaintainScope,
115117 AuthTerminalOperateScope,
116118 AuthReviewWriteScope,
119 AuthSourceControlWriteScope,
117120 AuthRelayReadScope,
118121] as const;
119122export const AuthAdministrativeScopes = [
packages/contracts/src/clientRpcPermissions.ts+40-2
......@@ -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";
28import { WS_METHODS } from "./rpc.ts";
39
410/** Incremental client enforcement; the server still authorizes every request. */
511export 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
637 [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope,
738 [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope,
839 [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope,
......@@ -11,10 +42,17 @@ export const CLIENT_GUARDED_RPC_SCOPES = {
1142} as const;
1243export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES;
1344
45const decodePrepareThread = Schema.decodeUnknownSync(GitPreparePullRequestThreadInput);
46
1447export function clientRpcRequiredScopes(
1548 method: string,
16 _input: unknown,
49 input: unknown,
1750): 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 }
1856 return Object.hasOwn(CLIENT_GUARDED_RPC_SCOPES, method)
1957 ? [CLIENT_GUARDED_RPC_SCOPES[method as ClientGuardedRpcTag]]
2058 : [];