| author | |
| committer | |
| log | cf1ba3d7da98357243ee056d2fd358eed8171981 |
| tree | 4f470486463d6bb8c2e52b1683b46b9174fe7a46 |
| parent | 4d16e6bc6f0be52c5d44b8864000c70c7825e4f7 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 467 | const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; |
| 468 | 468 | const startFromOrigin = |
| 469 | 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; | |
| 471 | 474 | |
| 472 | 475 | // Antigravity keeps unavailable selections so sign-out or a catalog change |
| 473 | 476 | // cannot switch the user's model. Other providers retain their fallback |
| ... | ... | @@ -978,7 +981,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 978 | 981 | text, |
| 979 | 982 | attachments: draft.attachments, |
| 980 | 983 | modelSelection: draftModelSelection, |
| 981 | runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE, | |
| 984 | runtimeMode: draft.runtimeMode ?? defaultRuntimeMode, | |
| 982 | 985 | interactionMode: resolvePendingTaskInteractionMode({ |
| 983 | 986 | preferenceLoaded: planModePreferenceLoaded, |
| 984 | 987 | planModeEnabled: legacyPlanModeEnabled, |
| ... | ... | @@ -1014,6 +1017,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 1014 | 1017 | }; |
| 1015 | 1018 | }, |
| 1016 | 1019 | [ |
| 1020 | defaultRuntimeMode, | |
| 1017 | 1021 | editingPendingProject, |
| 1018 | 1022 | editingPendingTask, |
| 1019 | 1023 | selectedEnvironmentServerConfig, |
apps/server/src/serverRuntimeStartup.test.ts+37-6| ... | ... | @@ -225,12 +225,37 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa |
| 225 | 225 | }); |
| 226 | 226 | |
| 227 | 227 | it.effect.each([ |
| 228 | { existing: false, machineModel: null, projectModel: null }, | |
| 229 | { existing: false, machineModel: "claude-sonnet-4-6", projectModel: null }, | |
| 230 | { existing: true, machineModel: "claude-sonnet-4-6", projectModel: null }, | |
| 231 | { existing: true, machineModel: "claude-sonnet-4-6", projectModel: "gpt-5.4" }, | |
| 232 | ])("auto-bootstrap model precedence: %j", ({ existing, machineModel, projectModel }) => | |
| 228 | { | |
| 229 | existing: false, | |
| 230 | machineModel: null, | |
| 231 | projectModel: null, | |
| 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 | 257 | Effect.gen(function* () { |
| 258 | const { existing, machineModel, projectModel, machineMode, projectMode } = options; | |
| 234 | 259 | const machineSelection = machineModel |
| 235 | 260 | ? { instanceId: ProviderInstanceId.make("claude-code"), model: machineModel } |
| 236 | 261 | : null; |
| ... | ... | @@ -242,16 +267,21 @@ it.effect.each([ |
| 242 | 267 | readonly type: string; |
| 243 | 268 | readonly defaultModelSelection?: unknown; |
| 244 | 269 | readonly modelSelection?: unknown; |
| 270 | readonly runtimeMode?: unknown; | |
| 245 | 271 | }> |
| 246 | 272 | >([]); |
| 247 | 273 | const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( |
| 248 | 274 | Effect.provide( |
| 249 | 275 | ServerSettings.layerTest({ |
| 250 | 276 | defaultModelSelection: machineSelection, |
| 277 | defaultRuntimeMode: machineMode, | |
| 251 | 278 | projectSettingsOverrides: |
| 252 | 279 | 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 | 351 | existing ? ["thread.create"] : ["project.create", "thread.create"], |
| 322 | 352 | ); |
| 323 | 353 | if (!existing) assert.equal("defaultModelSelection" in commands[0]!, false); |
| 354 | assert.equal(commands.at(-1)?.runtimeMode, projectMode ?? machineMode); | |
| 324 | 355 | assert.deepStrictEqual( |
| 325 | 356 | commands.at(-1)?.modelSelection, |
| 326 | 357 | projectSelection ?? |
apps/server/src/serverRuntimeStartup.ts+2-1| ... | ... | @@ -248,7 +248,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { |
| 248 | 248 | title: "New thread", |
| 249 | 249 | modelSelection: nextThreadModelSelection, |
| 250 | 250 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 251 | runtimeMode: "full-access", | |
| 251 | runtimeMode: resolveProjectSettings(settings, nextProjectId).settings | |
| 252 | .defaultRuntimeMode, | |
| 252 | 253 | branch: null, |
| 253 | 254 | worktreePath: null, |
| 254 | 255 | createdAt, |
apps/web/src/components/ChatView.tsx+11-6| ... | ... | @@ -154,7 +154,6 @@ import { |
| 154 | 154 | } from "../proposedPlan"; |
| 155 | 155 | import { |
| 156 | 156 | DEFAULT_INTERACTION_MODE, |
| 157 | DEFAULT_RUNTIME_MODE, | |
| 158 | 157 | DEFAULT_THREAD_TERMINAL_ID, |
| 159 | 158 | MAX_TERMINALS_PER_GROUP, |
| 160 | 159 | type ChatMessage, |
| ... | ... | @@ -1839,7 +1838,11 @@ export default function ChatView(props: ChatViewProps) { |
| 1839 | 1838 | // session.lastError. Bump a tick so the banner hides immediately. Mirrors |
| 1840 | 1839 | // the branch mismatch banner. |
| 1841 | 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 | 1846 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; |
| 1844 | 1847 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; |
| 1845 | 1848 | const activeThreadId = activeThread?.id ?? null; |
| ... | ... | @@ -2293,7 +2296,8 @@ export default function ChatView(props: ChatViewProps) { |
| 2293 | 2296 | setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { |
| 2294 | 2297 | threadId: nextThreadId, |
| 2295 | 2298 | createdAt: new Date().toISOString(), |
| 2296 | runtimeMode: DEFAULT_RUNTIME_MODE, | |
| 2299 | runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings | |
| 2300 | .defaultRuntimeMode, | |
| 2297 | 2301 | interactionMode: DEFAULT_INTERACTION_MODE, |
| 2298 | 2302 | ...input, |
| 2299 | 2303 | }); |
| ... | ... | @@ -2312,6 +2316,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2312 | 2316 | navigate, |
| 2313 | 2317 | projectGroupingSettings, |
| 2314 | 2318 | routeKind, |
| 2319 | settings, | |
| 2315 | 2320 | setDraftThreadContext, |
| 2316 | 2321 | setLogicalProjectDraftThreadId, |
| 2317 | 2322 | ], |
| ... | ... | @@ -7890,7 +7895,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7890 | 7895 | projectId: activeProject.id, |
| 7891 | 7896 | title: nextThreadTitle, |
| 7892 | 7897 | modelSelection: nextThreadModelSelection, |
| 7893 | runtimeMode, | |
| 7898 | runtimeMode: defaultRuntimeMode, | |
| 7894 | 7899 | interactionMode: "default", |
| 7895 | 7900 | branch: activeThreadBranch, |
| 7896 | 7901 | worktreePath: activeThread.worktreePath, |
| ... | ... | @@ -7913,7 +7918,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7913 | 7918 | }, |
| 7914 | 7919 | modelSelection: ctxSelectedModelSelection, |
| 7915 | 7920 | titleSeed: nextThreadTitle, |
| 7916 | runtimeMode, | |
| 7921 | runtimeMode: defaultRuntimeMode, | |
| 7917 | 7922 | interactionMode: "default", |
| 7918 | 7923 | sourceProposedPlan: { |
| 7919 | 7924 | threadId: activeThread.id, |
| ... | ... | @@ -7987,7 +7992,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7987 | 7992 | isServerThread, |
| 7988 | 7993 | navigate, |
| 7989 | 7994 | resetLocalDispatch, |
| 7990 | runtimeMode, | |
| 7995 | defaultRuntimeMode, | |
| 7991 | 7996 | startThreadTurn, |
| 7992 | 7997 | environmentId, |
| 7993 | 7998 | composerRef, |
apps/web/src/components/chat/ChatComposer.tsx+1-32| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; | |
| 1 | 2 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | 3 | import { |
| 3 | 4 | questionAttachmentDraftId, |
| ... | ... | @@ -850,11 +851,6 @@ import { |
| 850 | 851 | PaperclipIcon, |
| 851 | 852 | PencilRulerIcon, |
| 852 | 853 | PlayIcon, |
| 853 | type LucideIcon, | |
| 854 | LockIcon, | |
| 855 | LockOpenIcon, | |
| 856 | PenLineIcon, | |
| 857 | SparklesIcon, | |
| 858 | 854 | XIcon, |
| 859 | 855 | } from "lucide-react"; |
| 860 | 856 | import { proposedPlanTitle } from "../../proposedPlan"; |
| ... | ... | @@ -898,33 +894,6 @@ import type { ReviewCommentContext } from "../../reviewCommentContext"; |
| 898 | 894 | |
| 899 | 895 | const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; |
| 900 | 896 | |
| 901 | const 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 | ||
| 927 | const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; | |
| 928 | 897 | const extendReplacementRangeForTrailingSpace = ( |
| 929 | 898 | text: string, |
| 930 | 899 | rangeEnd: number, |
apps/web/src/components/chat/runtimeModeConfig.ts created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | import type { RuntimeMode } from "@t3tools/contracts"; | |
| 2 | import { type LucideIcon, LockIcon, LockOpenIcon, PenLineIcon, SparklesIcon } from "lucide-react"; | |
| 3 | ||
| 4 | export 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 | ||
| 30 | export 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 | 19 | import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; |
| 20 | 20 | import { resolveEnvModeLabel } from "../BranchToolbar.logic"; |
| 21 | 21 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; |
| 22 | import { runtimeModeConfig, runtimeModeOptions } from "../chat/runtimeModeConfig"; | |
| 22 | 23 | import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; |
| 23 | 24 | import { TraitsPicker } from "../chat/TraitsPicker"; |
| 24 | 25 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| ... | ... | @@ -67,6 +68,8 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting |
| 67 | 68 | ); |
| 68 | 69 | const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); |
| 69 | 70 | const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]); |
| 71 | const mixedPermissions = useScopedSettingsMixed(["defaultRuntimeMode"]); | |
| 72 | const PermissionIcon = runtimeModeConfig[settings.defaultRuntimeMode].icon; | |
| 70 | 73 | const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]); |
| 71 | 74 | const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]); |
| 72 | 75 | const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]); |
| ... | ... | @@ -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 | 287 | <SettingsRow |
| 224 | 288 | serverScoped |
| 225 | 289 | settingKeys={["defaultThreadEnvMode"]} |
apps/web/src/components/settings/settingsSearch.ts+9| ... | ... | @@ -108,6 +108,15 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 108 | 108 | scope: "project-defaults", |
| 109 | 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 | 121 | id: "color-scheme", |
| 113 | 122 | title: "Color scheme", |
apps/web/src/hooks/useHandleNewThread.test.ts+25| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import type { RuntimeMode } from "@t3tools/contracts"; | |
| 2 | 3 | |
| 3 | 4 | const testState = vi.hoisted(() => { |
| 4 | 5 | let completeProjectFileRead: (value: null) => void = () => undefined; |
| ... | ... | @@ -7,6 +8,7 @@ const testState = vi.hoisted(() => { |
| 7 | 8 | defaultThreadEnvMode: "local" as "local" | "worktree", |
| 8 | 9 | newWorktreesStartFromOrigin: false, |
| 9 | 10 | defaultModelSelection: null, |
| 11 | defaultRuntimeMode: "full-access" as RuntimeMode, | |
| 10 | 12 | }; |
| 11 | 13 | let storedDraft: { |
| 12 | 14 | readonly draftId: string; |
| ... | ... | @@ -55,6 +57,7 @@ const testState = vi.hoisted(() => { |
| 55 | 57 | defaultThreadEnvMode: workspaceDefaults.envMode, |
| 56 | 58 | newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin, |
| 57 | 59 | defaultModelSelection: null, |
| 60 | defaultRuntimeMode: "full-access", | |
| 58 | 61 | }; |
| 59 | 62 | router.state.location.href = "/"; |
| 60 | 63 | router.navigate.mockClear(); |
| ... | ... | @@ -184,6 +187,28 @@ describe.each([ |
| 184 | 187 | }, |
| 185 | 188 | ], |
| 186 | 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 | 212 | it("abandons a delayed draft open when the user navigates elsewhere", async () => { |
| 188 | 213 | testState.reset(draft); |
| 189 | 214 | const openThread = useNewThreadHandler(); |
apps/web/src/hooks/useHandleNewThread.ts+7-16| ... | ... | @@ -4,12 +4,7 @@ import { |
| 4 | 4 | scopeProjectRef, |
| 5 | 5 | scopeThreadRef, |
| 6 | 6 | } from "@t3tools/client-runtime/environment"; |
| 7 | import { | |
| 8 | DEFAULT_RUNTIME_MODE, | |
| 9 | DEFAULT_SERVER_SETTINGS, | |
| 10 | type ScopedProjectRef, | |
| 11 | type ThreadId, | |
| 12 | } from "@t3tools/contracts"; | |
| 7 | import { DEFAULT_SERVER_SETTINGS, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts"; | |
| 13 | 8 | import { useParams, useRouter } from "@tanstack/react-router"; |
| 14 | 9 | import { useCallback, useMemo } from "react"; |
| 15 | 10 | import { |
| ... | ... | @@ -100,8 +95,8 @@ export function useNewThreadHandler() { |
| 100 | 95 | const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref; |
| 101 | 96 | const currentRouteTarget = getCurrentRouteTarget(); |
| 102 | 97 | // A new thread carries the user's working mode from the thread being |
| 103 | // viewed. The target project's configured model still wins; runtime and | |
| 104 | // interaction modes carry independently. Branch, worktree, and env mode | |
| 98 | // viewed. The target project's configured model still wins; interaction | |
| 99 | // mode carries independently. Permissions, branch, worktree, and env mode | |
| 105 | 100 | // come from configured defaults unless the caller passes them explicitly. |
| 106 | 101 | const carrySourceShell = |
| 107 | 102 | currentRouteTarget?.kind === "server" |
| ... | ... | @@ -124,11 +119,6 @@ export function useNewThreadHandler() { |
| 124 | 119 | : null; |
| 125 | 120 | const carryModelSelection = |
| 126 | 121 | composerModelSelection ?? carrySourceShell?.modelSelection ?? null; |
| 127 | const carryRuntimeMode = | |
| 128 | carrySourceComposer?.runtimeMode ?? | |
| 129 | carrySourceShell?.runtimeMode ?? | |
| 130 | carrySourceDraft?.runtimeMode ?? | |
| 131 | null; | |
| 132 | 122 | const carryInteractionMode = |
| 133 | 123 | carrySourceComposer?.interactionMode ?? |
| 134 | 124 | carrySourceShell?.interactionMode ?? |
| ... | ... | @@ -147,6 +137,7 @@ export function useNewThreadHandler() { |
| 147 | 137 | project, |
| 148 | 138 | ); |
| 149 | 139 | const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection; |
| 140 | const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; | |
| 150 | 141 | const projectThreadEnvMode = |
| 151 | 142 | projectSettings.sources.defaultThreadEnvMode === "project" |
| 152 | 143 | ? projectSettings.settings.defaultThreadEnvMode |
| ... | ... | @@ -276,7 +267,7 @@ export function useNewThreadHandler() { |
| 276 | 267 | if (workspaceContext) { |
| 277 | 268 | setDraftThreadContext(emptyStoredDraftThread.draftId, { |
| 278 | 269 | ...workspaceContext, |
| 279 | ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), | |
| 270 | ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), | |
| 280 | 271 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), |
| 281 | 272 | }); |
| 282 | 273 | } |
| ... | ... | @@ -313,7 +304,7 @@ export function useNewThreadHandler() { |
| 313 | 304 | { |
| 314 | 305 | threadId: emptyStoredDraftThread.threadId, |
| 315 | 306 | ...workspaceContext, |
| 316 | ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), | |
| 307 | ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), | |
| 317 | 308 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), |
| 318 | 309 | }, |
| 319 | 310 | ); |
| ... | ... | @@ -426,7 +417,7 @@ export function useNewThreadHandler() { |
| 426 | 417 | envMode: initialEnvMode, |
| 427 | 418 | newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin, |
| 428 | 419 | }), |
| 429 | runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE, | |
| 420 | runtimeMode: defaultRuntimeMode, | |
| 430 | 421 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), |
| 431 | 422 | }); |
| 432 | 423 | applyStickyState(draftId); |
docs/user/permission-modes.md+4-2| ... | ... | @@ -3,8 +3,10 @@ |
| 3 | 3 | Permission modes control when an agent needs your approval to act. Choose a mode in the message |
| 4 | 4 | composer; it applies to that thread. |
| 5 | 5 | |
| 6 | New threads start in **Full access** unless you choose another mode before sending. A thread | |
| 7 | created from another thread inherits its mode. | |
| 6 | Set the default for new threads in **Settings → General → New threads → Permissions**. | |
| 7 | Projects can override the environment default. New threads use this setting rather than the | |
| 8 | mode of the thread you were viewing. The initial default is **Full access**; existing threads | |
| 9 | and modes you choose in a draft keep their permissions. | |
| 8 | 10 | |
| 9 | 11 | | Mode | Behavior | |
| 10 | 12 | | --------------------- | ------------------------------------------------------------------------------------- | |
packages/contracts/src/settings.test.ts+29| ... | ... | @@ -20,6 +20,35 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); |
| 20 | 20 | const encodeServerSettings = Schema.encodeSync(ServerSettings); |
| 21 | 21 | const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); |
| 22 | 22 | |
| 23 | describe("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 | ||
| 23 | 52 | describe("ServerSettings usage price overrides", () => { |
| 24 | 53 | const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }; |
| 25 | 54 |
packages/contracts/src/settings.ts+12-1| ... | ... | @@ -18,7 +18,12 @@ import { |
| 18 | 18 | DEFAULT_TEXT_GENERATION_REASONING_EFFORT, |
| 19 | 19 | ProviderOptionSelections, |
| 20 | 20 | } from "./model.ts"; |
| 21 | import { ModelSelection, ProjectScript } from "./orchestration.ts"; | |
| 21 | import { | |
| 22 | DEFAULT_RUNTIME_MODE, | |
| 23 | ModelSelection, | |
| 24 | ProjectScript, | |
| 25 | RuntimeMode, | |
| 26 | } from "./orchestration.ts"; | |
| 22 | 27 | import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; |
| 23 | 28 | import { |
| 24 | 29 | DEFAULT_PREVIEW_APPEARANCE, |
| ... | ... | @@ -934,6 +939,7 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; |
| 934 | 939 | */ |
| 935 | 940 | export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ |
| 936 | 941 | "defaultModelSelection", |
| 942 | "defaultRuntimeMode", | |
| 937 | 943 | "defaultThreadEnvMode", |
| 938 | 944 | "newWorktreesStartFromOrigin", |
| 939 | 945 | "defaultAutoPull", |
| ... | ... | @@ -958,6 +964,7 @@ export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTIN |
| 958 | 964 | */ |
| 959 | 965 | export const ProjectSettingsOverrides = Schema.Struct({ |
| 960 | 966 | defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), |
| 967 | defaultRuntimeMode: Schema.optionalKey(RuntimeMode), | |
| 961 | 968 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), |
| 962 | 969 | newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), |
| 963 | 970 | defaultAutoPull: Schema.optionalKey(Schema.Boolean), |
| ... | ... | @@ -1015,6 +1022,9 @@ export const ServerSettings = Schema.Struct({ |
| 1015 | 1022 | defaultModelSelection: Schema.NullOr(ModelSelection).pipe( |
| 1016 | 1023 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 1017 | 1024 | ), |
| 1025 | defaultRuntimeMode: RuntimeMode.pipe( | |
| 1026 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), | |
| 1027 | ), | |
| 1018 | 1028 | /** |
| 1019 | 1029 | * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`. |
| 1020 | 1030 | * The source of truth for project settings; `projectAgentBrowserAccessOverrides`, |
| ... | ... | @@ -1328,6 +1338,7 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1328 | 1338 | Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), |
| 1329 | 1339 | ), |
| 1330 | 1340 | defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), |
| 1341 | defaultRuntimeMode: Schema.optionalKey(RuntimeMode), | |
| 1331 | 1342 | /** |
| 1332 | 1343 | * Per-project entry replacement: each entry replaces that project's whole |
| 1333 | 1344 | * override set and `null` removes it. Clearing one override means resending |