authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-11 22:14:17-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 22:14:17-03:00
logcf1ba3d7da98357243ee056d2fd358eed8171981
tree4f470486463d6bb8c2e52b1683b46b9174fe7a46
parent4d16e6bc6f0be52c5d44b8864000c70c7825e4f7
signature Signed by PGP key B5690EEEBB952194

feat(settings): configure default permissions for new threads (#11346)


13 files changed, 237 insertions(+), 66 deletions(-)

apps/mobile/src/features/threads/new-task-flow-provider.tsx+6-2
...@@ -467,7 +467,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -467,7 +467,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
467 const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin;467 const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin;
468 const startFromOrigin =468 const startFromOrigin =
469 draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin;469 draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin;
470 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;470 const defaultRuntimeMode = editingPendingTask
471 ? (editingPendingTask.runtimeMode ?? DEFAULT_RUNTIME_MODE)
472 : projectSettings.settings.defaultRuntimeMode;
473 const runtimeMode = selectedProjectDraft.runtimeMode ?? defaultRuntimeMode;
471474
472 // Antigravity keeps unavailable selections so sign-out or a catalog change475 // Antigravity keeps unavailable selections so sign-out or a catalog change
473 // cannot switch the user's model. Other providers retain their fallback476 // cannot switch the user's model. Other providers retain their fallback
...@@ -978,7 +981,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -978,7 +981,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
978 text,981 text,
979 attachments: draft.attachments,982 attachments: draft.attachments,
980 modelSelection: draftModelSelection,983 modelSelection: draftModelSelection,
981 runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE,984 runtimeMode: draft.runtimeMode ?? defaultRuntimeMode,
982 interactionMode: resolvePendingTaskInteractionMode({985 interactionMode: resolvePendingTaskInteractionMode({
983 preferenceLoaded: planModePreferenceLoaded,986 preferenceLoaded: planModePreferenceLoaded,
984 planModeEnabled: legacyPlanModeEnabled,987 planModeEnabled: legacyPlanModeEnabled,
...@@ -1014,6 +1017,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -1014,6 +1017,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
1014 };1017 };
1015 },1018 },
1016 [1019 [
1020 defaultRuntimeMode,
1017 editingPendingProject,1021 editingPendingProject,
1018 editingPendingTask,1022 editingPendingTask,
1019 selectedEnvironmentServerConfig,1023 selectedEnvironmentServerConfig,
apps/server/src/serverRuntimeStartup.test.ts+37-6
...@@ -225,12 +225,37 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa...@@ -225,12 +225,37 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
225});225});
226226
227it.effect.each([227it.effect.each([
228 { existing: false, machineModel: null, projectModel: null },228 {
229 { existing: false, machineModel: "claude-sonnet-4-6", projectModel: null },229 existing: false,
230 { existing: true, machineModel: "claude-sonnet-4-6", projectModel: null },230 machineModel: null,
231 { existing: true, machineModel: "claude-sonnet-4-6", projectModel: "gpt-5.4" },231 projectModel: null,
232])("auto-bootstrap model precedence: %j", ({ existing, machineModel, projectModel }) =>232 machineMode: "full-access",
233 projectMode: null,
234 },
235 {
236 existing: false,
237 machineModel: "claude-sonnet-4-6",
238 projectModel: null,
239 machineMode: "approval-required",
240 projectMode: null,
241 },
242 {
243 existing: true,
244 machineModel: "claude-sonnet-4-6",
245 projectModel: null,
246 machineMode: "auto",
247 projectMode: null,
248 },
249 {
250 existing: true,
251 machineModel: "claude-sonnet-4-6",
252 projectModel: "gpt-5.4",
253 machineMode: "full-access",
254 projectMode: "auto-accept-edits",
255 },
256] as const)("auto-bootstrap model and permissions precedence: %j", (options) =>
233 Effect.gen(function* () {257 Effect.gen(function* () {
258 const { existing, machineModel, projectModel, machineMode, projectMode } = options;
234 const machineSelection = machineModel259 const machineSelection = machineModel
235 ? { instanceId: ProviderInstanceId.make("claude-code"), model: machineModel }260 ? { instanceId: ProviderInstanceId.make("claude-code"), model: machineModel }
236 : null;261 : null;
...@@ -242,16 +267,21 @@ it.effect.each([...@@ -242,16 +267,21 @@ it.effect.each([
242 readonly type: string;267 readonly type: string;
243 readonly defaultModelSelection?: unknown;268 readonly defaultModelSelection?: unknown;
244 readonly modelSelection?: unknown;269 readonly modelSelection?: unknown;
270 readonly runtimeMode?: unknown;
245 }>271 }>
246 >([]);272 >([]);
247 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(273 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
248 Effect.provide(274 Effect.provide(
249 ServerSettings.layerTest({275 ServerSettings.layerTest({
250 defaultModelSelection: machineSelection,276 defaultModelSelection: machineSelection,
277 defaultRuntimeMode: machineMode,
251 projectSettingsOverrides:278 projectSettingsOverrides:
252 existing && projectSelection279 existing && projectSelection
253 ? {280 ? {
254 [ProjectId.make("existing-project")]: { defaultModelSelection: projectSelection },281 [ProjectId.make("existing-project")]: {
282 defaultModelSelection: projectSelection,
283 ...(projectMode ? { defaultRuntimeMode: projectMode } : {}),
284 },
255 }285 }
256 : {},286 : {},
257 }),287 }),
...@@ -321,6 +351,7 @@ it.effect.each([...@@ -321,6 +351,7 @@ it.effect.each([
321 existing ? ["thread.create"] : ["project.create", "thread.create"],351 existing ? ["thread.create"] : ["project.create", "thread.create"],
322 );352 );
323 if (!existing) assert.equal("defaultModelSelection" in commands[0]!, false);353 if (!existing) assert.equal("defaultModelSelection" in commands[0]!, false);
354 assert.equal(commands.at(-1)?.runtimeMode, projectMode ?? machineMode);
324 assert.deepStrictEqual(355 assert.deepStrictEqual(
325 commands.at(-1)?.modelSelection,356 commands.at(-1)?.modelSelection,
326 projectSelection ??357 projectSelection ??
apps/server/src/serverRuntimeStartup.ts+2-1
...@@ -248,7 +248,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {...@@ -248,7 +248,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
248 title: "New thread",248 title: "New thread",
249 modelSelection: nextThreadModelSelection,249 modelSelection: nextThreadModelSelection,
250 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,250 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
251 runtimeMode: "full-access",251 runtimeMode: resolveProjectSettings(settings, nextProjectId).settings
252 .defaultRuntimeMode,
252 branch: null,253 branch: null,
253 worktreePath: null,254 worktreePath: null,
254 createdAt,255 createdAt,
apps/web/src/components/ChatView.tsx+11-6
...@@ -154,7 +154,6 @@ import {...@@ -154,7 +154,6 @@ import {
154} from "../proposedPlan";154} from "../proposedPlan";
155import {155import {
156 DEFAULT_INTERACTION_MODE,156 DEFAULT_INTERACTION_MODE,
157 DEFAULT_RUNTIME_MODE,
158 DEFAULT_THREAD_TERMINAL_ID,157 DEFAULT_THREAD_TERMINAL_ID,
159 MAX_TERMINALS_PER_GROUP,158 MAX_TERMINALS_PER_GROUP,
160 type ChatMessage,159 type ChatMessage,
...@@ -1839,7 +1838,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -1839,7 +1838,11 @@ export default function ChatView(props: ChatViewProps) {
1839 // session.lastError. Bump a tick so the banner hides immediately. Mirrors1838 // session.lastError. Bump a tick so the banner hides immediately. Mirrors
1840 // the branch mismatch banner.1839 // the branch mismatch banner.
1841 const [, setThreadErrorBannerDismissTick] = useState(0);1840 const [, setThreadErrorBannerDismissTick] = useState(0);
1842 const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;1841 const defaultRuntimeMode = resolveProjectSettings(settings, activeThread?.projectId ?? null)
1842 .settings.defaultRuntimeMode;
1843 // Implicit drafts follow their current project/environment, including retargets.
1844 // Explicit composer choices and existing server threads retain their permissions.
1845 const runtimeMode = composerRuntimeMode ?? activeServerThread?.runtimeMode ?? defaultRuntimeMode;
1843 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;1846 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
1844 const canCheckoutPullRequestIntoThread = isLocalDraftThread;1847 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
1845 const activeThreadId = activeThread?.id ?? null;1848 const activeThreadId = activeThread?.id ?? null;
...@@ -2293,7 +2296,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -2293,7 +2296,8 @@ export default function ChatView(props: ChatViewProps) {
2293 setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, {2296 setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, {
2294 threadId: nextThreadId,2297 threadId: nextThreadId,
2295 createdAt: new Date().toISOString(),2298 createdAt: new Date().toISOString(),
2296 runtimeMode: DEFAULT_RUNTIME_MODE,2299 runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings
2300 .defaultRuntimeMode,
2297 interactionMode: DEFAULT_INTERACTION_MODE,2301 interactionMode: DEFAULT_INTERACTION_MODE,
2298 ...input,2302 ...input,
2299 });2303 });
...@@ -2312,6 +2316,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2312,6 +2316,7 @@ export default function ChatView(props: ChatViewProps) {
2312 navigate,2316 navigate,
2313 projectGroupingSettings,2317 projectGroupingSettings,
2314 routeKind,2318 routeKind,
2319 settings,
2315 setDraftThreadContext,2320 setDraftThreadContext,
2316 setLogicalProjectDraftThreadId,2321 setLogicalProjectDraftThreadId,
2317 ],2322 ],
...@@ -7890,7 +7895,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7890,7 +7895,7 @@ export default function ChatView(props: ChatViewProps) {
7890 projectId: activeProject.id,7895 projectId: activeProject.id,
7891 title: nextThreadTitle,7896 title: nextThreadTitle,
7892 modelSelection: nextThreadModelSelection,7897 modelSelection: nextThreadModelSelection,
7893 runtimeMode,7898 runtimeMode: defaultRuntimeMode,
7894 interactionMode: "default",7899 interactionMode: "default",
7895 branch: activeThreadBranch,7900 branch: activeThreadBranch,
7896 worktreePath: activeThread.worktreePath,7901 worktreePath: activeThread.worktreePath,
...@@ -7913,7 +7918,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7913,7 +7918,7 @@ export default function ChatView(props: ChatViewProps) {
7913 },7918 },
7914 modelSelection: ctxSelectedModelSelection,7919 modelSelection: ctxSelectedModelSelection,
7915 titleSeed: nextThreadTitle,7920 titleSeed: nextThreadTitle,
7916 runtimeMode,7921 runtimeMode: defaultRuntimeMode,
7917 interactionMode: "default",7922 interactionMode: "default",
7918 sourceProposedPlan: {7923 sourceProposedPlan: {
7919 threadId: activeThread.id,7924 threadId: activeThread.id,
...@@ -7987,7 +7992,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7987,7 +7992,7 @@ export default function ChatView(props: ChatViewProps) {
7987 isServerThread,7992 isServerThread,
7988 navigate,7993 navigate,
7989 resetLocalDispatch,7994 resetLocalDispatch,
7990 runtimeMode,7995 defaultRuntimeMode,
7991 startThreadTurn,7996 startThreadTurn,
7992 environmentId,7997 environmentId,
7993 composerRef,7998 composerRef,
apps/web/src/components/chat/ChatComposer.tsx+1-32
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig";
1import { RefreshIcon } from "~/components/ui/refresh-icon";2import { RefreshIcon } from "~/components/ui/refresh-icon";
2import {3import {
3 questionAttachmentDraftId,4 questionAttachmentDraftId,
...@@ -850,11 +851,6 @@ import {...@@ -850,11 +851,6 @@ import {
850 PaperclipIcon,851 PaperclipIcon,
851 PencilRulerIcon,852 PencilRulerIcon,
852 PlayIcon,853 PlayIcon,
853 type LucideIcon,
854 LockIcon,
855 LockOpenIcon,
856 PenLineIcon,
857 SparklesIcon,
858 XIcon,854 XIcon,
859} from "lucide-react";855} from "lucide-react";
860import { proposedPlanTitle } from "../../proposedPlan";856import { proposedPlanTitle } from "../../proposedPlan";
...@@ -898,33 +894,6 @@ import type { ReviewCommentContext } from "../../reviewCommentContext";...@@ -898,33 +894,6 @@ import type { ReviewCommentContext } from "../../reviewCommentContext";
898894
899const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000;895const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000;
900896
901const runtimeModeConfig: Record<
902 RuntimeMode,
903 { label: string; description: string; icon: LucideIcon }
904> = {
905 "approval-required": {
906 label: "Supervised",
907 description: "Ask before commands and file changes.",
908 icon: LockIcon,
909 },
910 "auto-accept-edits": {
911 label: "Auto-accept edits",
912 description: "Auto-approve edits, ask before other actions.",
913 icon: PenLineIcon,
914 },
915 auto: {
916 label: "Auto",
917 description: "Supported providers approve routine actions; others still ask.",
918 icon: SparklesIcon,
919 },
920 "full-access": {
921 label: "Full access",
922 description: "Allow commands and edits without prompts.",
923 icon: LockOpenIcon,
924 },
925};
926
927const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
928const extendReplacementRangeForTrailingSpace = (897const extendReplacementRangeForTrailingSpace = (
929 text: string,898 text: string,
930 rangeEnd: number,899 rangeEnd: number,
apps/web/src/components/chat/runtimeModeConfig.ts created+30
...@@ -0,0 +1,30 @@
1import type { RuntimeMode } from "@t3tools/contracts";
2import { type LucideIcon, LockIcon, LockOpenIcon, PenLineIcon, SparklesIcon } from "lucide-react";
3
4export const runtimeModeConfig: Record<
5 RuntimeMode,
6 { label: string; description: string; icon: LucideIcon }
7> = {
8 "approval-required": {
9 label: "Supervised",
10 description: "Ask before commands and file changes.",
11 icon: LockIcon,
12 },
13 "auto-accept-edits": {
14 label: "Auto-accept edits",
15 description: "Auto-approve edits, ask before other actions.",
16 icon: PenLineIcon,
17 },
18 auto: {
19 label: "Auto",
20 description: "Supported providers approve routine actions; others still ask.",
21 icon: SparklesIcon,
22 },
23 "full-access": {
24 label: "Full access",
25 description: "Allow commands and edits without prompts.",
26 icon: LockOpenIcon,
27 },
28};
29
30export const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
apps/web/src/components/settings/ProjectDefaultsSettings.tsx+64
...@@ -19,6 +19,7 @@ import { useEnvironments } from "../../state/environments";...@@ -19,6 +19,7 @@ import { useEnvironments } from "../../state/environments";
19import { EMPTY_SERVER_PROVIDERS } from "../../state/server";19import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
20import { resolveEnvModeLabel } from "../BranchToolbar.logic";20import { resolveEnvModeLabel } from "../BranchToolbar.logic";
21import { ProviderModelPicker } from "../chat/ProviderModelPicker";21import { ProviderModelPicker } from "../chat/ProviderModelPicker";
22import { runtimeModeConfig, runtimeModeOptions } from "../chat/runtimeModeConfig";
22import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";23import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
23import { TraitsPicker } from "../chat/TraitsPicker";24import { TraitsPicker } from "../chat/TraitsPicker";
24import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";25import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
...@@ -67,6 +68,8 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting...@@ -67,6 +68,8 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
67 );68 );
68 const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId);69 const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId);
69 const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]);70 const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]);
71 const mixedPermissions = useScopedSettingsMixed(["defaultRuntimeMode"]);
72 const PermissionIcon = runtimeModeConfig[settings.defaultRuntimeMode].icon;
70 const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]);73 const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]);
71 const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]);74 const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]);
72 const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);75 const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);
...@@ -220,6 +223,67 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting...@@ -220,6 +223,67 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
220 )223 )
221 }224 }
222 />225 />
226 <SettingsRow
227 serverScoped
228 settingKeys={["defaultRuntimeMode"]}
229 mixed={mixedPermissions}
230 {...searchableSetting("default-permissions")}
231 description={
232 isProjectScope
233 ? "Permissions for new threads in this project."
234 : "Default permissions for new threads. Projects can override them."
235 }
236 resetAction={
237 settings.defaultRuntimeMode !== DEFAULT_SERVER_SETTINGS.defaultRuntimeMode ? (
238 <SettingResetButton
239 label="default permissions"
240 onClick={() =>
241 updateSettings({
242 defaultRuntimeMode: DEFAULT_SERVER_SETTINGS.defaultRuntimeMode,
243 })
244 }
245 />
246 ) : null
247 }
248 control={
249 <Select
250 value={mixedPermissions ? null : settings.defaultRuntimeMode}
251 onValueChange={(value) => {
252 if (value) updateSettings({ defaultRuntimeMode: value });
253 }}
254 >
255 <SelectTrigger size="sm" aria-label="Default permissions">
256 {!mixedPermissions && (
257 <PermissionIcon className="size-3.5 shrink-0 text-muted-foreground" />
258 )}
259 <SelectValue>
260 {mixedPermissions
261 ? "Mixed"
262 : runtimeModeConfig[settings.defaultRuntimeMode].label}
263 </SelectValue>
264 </SelectTrigger>
265 <SelectPopup align="end" alignItemWithTrigger={false}>
266 {runtimeModeOptions.map((mode) => {
267 const option = runtimeModeConfig[mode];
268 const Icon = option.icon;
269 return (
270 <SelectItem key={mode} value={mode} className="min-w-64 py-2">
271 <div className="grid gap-0.5">
272 <span className="inline-flex items-center gap-1.5 font-medium">
273 <Icon className="size-3.5 shrink-0 text-muted-foreground" />
274 {option.label}
275 </span>
276 <span className="text-xs leading-4 text-muted-foreground">
277 {option.description}
278 </span>
279 </div>
280 </SelectItem>
281 );
282 })}
283 </SelectPopup>
284 </Select>
285 }
286 />
223 <SettingsRow287 <SettingsRow
224 serverScoped288 serverScoped
225 settingKeys={["defaultThreadEnvMode"]}289 settingKeys={["defaultThreadEnvMode"]}
apps/web/src/components/settings/settingsSearch.ts+9
...@@ -108,6 +108,15 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -108,6 +108,15 @@ export const SETTINGS_SEARCH_ITEMS = [
108 scope: "project-defaults",108 scope: "project-defaults",
109 searchTerms: ["new thread project provider reasoning effort"],109 searchTerms: ["new thread project provider reasoning effort"],
110 },110 },
111 {
112 id: "default-permissions",
113 title: "Permissions",
114 to: "/settings/general",
115 scope: "project-defaults",
116 searchTerms: [
117 "new thread default runtime mode supervised approvals auto accept edits full access",
118 ],
119 },
111 {120 {
112 id: "color-scheme",121 id: "color-scheme",
113 title: "Color scheme",122 title: "Color scheme",
apps/web/src/hooks/useHandleNewThread.test.ts+25
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
2import type { RuntimeMode } from "@t3tools/contracts";
23
3const testState = vi.hoisted(() => {4const testState = vi.hoisted(() => {
4 let completeProjectFileRead: (value: null) => void = () => undefined;5 let completeProjectFileRead: (value: null) => void = () => undefined;
...@@ -7,6 +8,7 @@ const testState = vi.hoisted(() => {...@@ -7,6 +8,7 @@ const testState = vi.hoisted(() => {
7 defaultThreadEnvMode: "local" as "local" | "worktree",8 defaultThreadEnvMode: "local" as "local" | "worktree",
8 newWorktreesStartFromOrigin: false,9 newWorktreesStartFromOrigin: false,
9 defaultModelSelection: null,10 defaultModelSelection: null,
11 defaultRuntimeMode: "full-access" as RuntimeMode,
10 };12 };
11 let storedDraft: {13 let storedDraft: {
12 readonly draftId: string;14 readonly draftId: string;
...@@ -55,6 +57,7 @@ const testState = vi.hoisted(() => {...@@ -55,6 +57,7 @@ const testState = vi.hoisted(() => {
55 defaultThreadEnvMode: workspaceDefaults.envMode,57 defaultThreadEnvMode: workspaceDefaults.envMode,
56 newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin,58 newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin,
57 defaultModelSelection: null,59 defaultModelSelection: null,
60 defaultRuntimeMode: "full-access",
58 };61 };
59 router.state.location.href = "/";62 router.state.location.href = "/";
60 router.navigate.mockClear();63 router.navigate.mockClear();
...@@ -184,6 +187,28 @@ describe.each([...@@ -184,6 +187,28 @@ describe.each([
184 },187 },
185 ],188 ],
186])("useNewThreadHandler with a %s draft", (_, draft) => {189])("useNewThreadHandler with a %s draft", (_, draft) => {
190 it.each(["approval-required", "auto-accept-edits", "auto", "full-access"] as const)(
191 "uses the target environment's %s permissions for new threads",
192 async (runtimeMode) => {
193 testState.reset(draft);
194 testState.targetSettings.defaultRuntimeMode = runtimeMode;
195 const projectRef = {
196 environmentId: "environment-ssh",
197 projectId: "project-remote",
198 } as never;
199 const pendingOpen = useNewThreadHandler()(projectRef);
200 testState.completeProjectFileRead(null);
201 const opened = await pendingOpen;
202
203 expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
204 "remote-project",
205 projectRef,
206 opened!.draftId,
207 expect.objectContaining({ runtimeMode }),
208 );
209 },
210 );
211
187 it("abandons a delayed draft open when the user navigates elsewhere", async () => {212 it("abandons a delayed draft open when the user navigates elsewhere", async () => {
188 testState.reset(draft);213 testState.reset(draft);
189 const openThread = useNewThreadHandler();214 const openThread = useNewThreadHandler();
apps/web/src/hooks/useHandleNewThread.ts+7-16
...@@ -4,12 +4,7 @@ import {...@@ -4,12 +4,7 @@ import {
4 scopeProjectRef,4 scopeProjectRef,
5 scopeThreadRef,5 scopeThreadRef,
6} from "@t3tools/client-runtime/environment";6} from "@t3tools/client-runtime/environment";
7import {7import { DEFAULT_SERVER_SETTINGS, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts";
8 DEFAULT_RUNTIME_MODE,
9 DEFAULT_SERVER_SETTINGS,
10 type ScopedProjectRef,
11 type ThreadId,
12} from "@t3tools/contracts";
13import { useParams, useRouter } from "@tanstack/react-router";8import { useParams, useRouter } from "@tanstack/react-router";
14import { useCallback, useMemo } from "react";9import { useCallback, useMemo } from "react";
15import {10import {
...@@ -100,8 +95,8 @@ export function useNewThreadHandler() {...@@ -100,8 +95,8 @@ export function useNewThreadHandler() {
100 const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref;95 const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref;
101 const currentRouteTarget = getCurrentRouteTarget();96 const currentRouteTarget = getCurrentRouteTarget();
102 // A new thread carries the user's working mode from the thread being97 // A new thread carries the user's working mode from the thread being
103 // viewed. The target project's configured model still wins; runtime and98 // viewed. The target project's configured model still wins; interaction
104 // interaction modes carry independently. Branch, worktree, and env mode99 // mode carries independently. Permissions, branch, worktree, and env mode
105 // come from configured defaults unless the caller passes them explicitly.100 // come from configured defaults unless the caller passes them explicitly.
106 const carrySourceShell =101 const carrySourceShell =
107 currentRouteTarget?.kind === "server"102 currentRouteTarget?.kind === "server"
...@@ -124,11 +119,6 @@ export function useNewThreadHandler() {...@@ -124,11 +119,6 @@ export function useNewThreadHandler() {
124 : null;119 : null;
125 const carryModelSelection =120 const carryModelSelection =
126 composerModelSelection ?? carrySourceShell?.modelSelection ?? null;121 composerModelSelection ?? carrySourceShell?.modelSelection ?? null;
127 const carryRuntimeMode =
128 carrySourceComposer?.runtimeMode ??
129 carrySourceShell?.runtimeMode ??
130 carrySourceDraft?.runtimeMode ??
131 null;
132 const carryInteractionMode =122 const carryInteractionMode =
133 carrySourceComposer?.interactionMode ??123 carrySourceComposer?.interactionMode ??
134 carrySourceShell?.interactionMode ??124 carrySourceShell?.interactionMode ??
...@@ -147,6 +137,7 @@ export function useNewThreadHandler() {...@@ -147,6 +137,7 @@ export function useNewThreadHandler() {
147 project,137 project,
148 );138 );
149 const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection;139 const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection;
140 const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode;
150 const projectThreadEnvMode =141 const projectThreadEnvMode =
151 projectSettings.sources.defaultThreadEnvMode === "project"142 projectSettings.sources.defaultThreadEnvMode === "project"
152 ? projectSettings.settings.defaultThreadEnvMode143 ? projectSettings.settings.defaultThreadEnvMode
...@@ -276,7 +267,7 @@ export function useNewThreadHandler() {...@@ -276,7 +267,7 @@ export function useNewThreadHandler() {
276 if (workspaceContext) {267 if (workspaceContext) {
277 setDraftThreadContext(emptyStoredDraftThread.draftId, {268 setDraftThreadContext(emptyStoredDraftThread.draftId, {
278 ...workspaceContext,269 ...workspaceContext,
279 ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),270 ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}),
280 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),271 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
281 });272 });
282 }273 }
...@@ -313,7 +304,7 @@ export function useNewThreadHandler() {...@@ -313,7 +304,7 @@ export function useNewThreadHandler() {
313 {304 {
314 threadId: emptyStoredDraftThread.threadId,305 threadId: emptyStoredDraftThread.threadId,
315 ...workspaceContext,306 ...workspaceContext,
316 ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),307 ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}),
317 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),308 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
318 },309 },
319 );310 );
...@@ -426,7 +417,7 @@ export function useNewThreadHandler() {...@@ -426,7 +417,7 @@ export function useNewThreadHandler() {
426 envMode: initialEnvMode,417 envMode: initialEnvMode,
427 newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin,418 newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin,
428 }),419 }),
429 runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE,420 runtimeMode: defaultRuntimeMode,
430 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),421 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
431 });422 });
432 applyStickyState(draftId);423 applyStickyState(draftId);
docs/user/permission-modes.md+4-2
...@@ -3,8 +3,10 @@...@@ -3,8 +3,10 @@
3Permission modes control when an agent needs your approval to act. Choose a mode in the message3Permission modes control when an agent needs your approval to act. Choose a mode in the message
4composer; it applies to that thread.4composer; it applies to that thread.
55
6New threads start in **Full access** unless you choose another mode before sending. A thread6Set the default for new threads in **Settings → General → New threads → Permissions**.
7created from another thread inherits its mode.7Projects can override the environment default. New threads use this setting rather than the
8mode of the thread you were viewing. The initial default is **Full access**; existing threads
9and modes you choose in a draft keep their permissions.
810
9| Mode | Behavior |11| Mode | Behavior |
10| --------------------- | ------------------------------------------------------------------------------------- |12| --------------------- | ------------------------------------------------------------------------------------- |
packages/contracts/src/settings.test.ts+29
...@@ -20,6 +20,35 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);...@@ -20,6 +20,35 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
20const encodeServerSettings = Schema.encodeSync(ServerSettings);20const encodeServerSettings = Schema.encodeSync(ServerSettings);
21const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);21const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);
2222
23describe("ServerSettings default permissions", () => {
24 it("keeps full access for settings saved before a default was configured", () => {
25 expect(decodeServerSettings({}).defaultRuntimeMode).toBe("full-access");
26 expect(DEFAULT_SERVER_SETTINGS.defaultRuntimeMode).toBe("full-access");
27 });
28
29 it.each(["approval-required", "auto-accept-edits", "auto", "full-access"])(
30 "round-trips %s as an environment default and project override",
31 (defaultRuntimeMode) => {
32 const input = {
33 defaultRuntimeMode,
34 projectSettingsOverrides: { project: { defaultRuntimeMode } },
35 };
36 expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input);
37 expect(decodeServerSettingsPatch(input)).toEqual(input);
38 },
39 );
40
41 it("rejects unsupported permission defaults", () => {
42 expect(() => decodeServerSettings({ defaultRuntimeMode: "unsupported" })).toThrow();
43 expect(() => decodeServerSettingsPatch({ defaultRuntimeMode: "unsupported" })).toThrow();
44 expect(() =>
45 decodeServerSettingsPatch({
46 projectSettingsOverrides: { project: { defaultRuntimeMode: "unsupported" } },
47 }),
48 ).toThrow();
49 });
50});
51
23describe("ServerSettings usage price overrides", () => {52describe("ServerSettings usage price overrides", () => {
24 const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };53 const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };
2554
packages/contracts/src/settings.ts+12-1
...@@ -18,7 +18,12 @@ import {...@@ -18,7 +18,12 @@ import {
18 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,18 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
19 ProviderOptionSelections,19 ProviderOptionSelections,
20} from "./model.ts";20} from "./model.ts";
21import { ModelSelection, ProjectScript } from "./orchestration.ts";21import {
22 DEFAULT_RUNTIME_MODE,
23 ModelSelection,
24 ProjectScript,
25 RuntimeMode,
26} from "./orchestration.ts";
22import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts";27import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts";
23import {28import {
24 DEFAULT_PREVIEW_APPEARANCE,29 DEFAULT_PREVIEW_APPEARANCE,
...@@ -934,6 +939,7 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;...@@ -934,6 +939,7 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;
934 */939 */
935export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [940export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
936 "defaultModelSelection",941 "defaultModelSelection",
942 "defaultRuntimeMode",
937 "defaultThreadEnvMode",943 "defaultThreadEnvMode",
938 "newWorktreesStartFromOrigin",944 "newWorktreesStartFromOrigin",
939 "defaultAutoPull",945 "defaultAutoPull",
...@@ -958,6 +964,7 @@ export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTIN...@@ -958,6 +964,7 @@ export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTIN
958 */964 */
959export const ProjectSettingsOverrides = Schema.Struct({965export const ProjectSettingsOverrides = Schema.Struct({
960 defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),966 defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
967 defaultRuntimeMode: Schema.optionalKey(RuntimeMode),
961 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),968 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
962 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),969 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
963 defaultAutoPull: Schema.optionalKey(Schema.Boolean),970 defaultAutoPull: Schema.optionalKey(Schema.Boolean),
...@@ -1015,6 +1022,9 @@ export const ServerSettings = Schema.Struct({...@@ -1015,6 +1022,9 @@ export const ServerSettings = Schema.Struct({
1015 defaultModelSelection: Schema.NullOr(ModelSelection).pipe(1022 defaultModelSelection: Schema.NullOr(ModelSelection).pipe(
1016 Schema.withDecodingDefault(Effect.succeed(null)),1023 Schema.withDecodingDefault(Effect.succeed(null)),
1017 ),1024 ),
1025 defaultRuntimeMode: RuntimeMode.pipe(
1026 Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)),
1027 ),
1018 /**1028 /**
1019 * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`.1029 * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`.
1020 * The source of truth for project settings; `projectAgentBrowserAccessOverrides`,1030 * The source of truth for project settings; `projectAgentBrowserAccessOverrides`,
...@@ -1328,6 +1338,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1328,6 +1338,7 @@ export const ServerSettingsPatch = Schema.Struct({
1328 Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),1338 Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),
1329 ),1339 ),
1330 defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),1340 defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
1341 defaultRuntimeMode: Schema.optionalKey(RuntimeMode),
1331 /**1342 /**
1332 * Per-project entry replacement: each entry replaces that project's whole1343 * Per-project entry replacement: each entry replaces that project's whole
1333 * override set and `null` removes it. Clearing one override means resending1344 * override set and `null` removes it. Clearing one override means resending