Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,9 +441,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {
},
},
automaticGitFetchInterval: Duration.seconds(10),
defaultRuntimeMode: "approval-required",
});

assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex");
assert.equal(next.defaultRuntimeMode, "approval-required");

const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
// @effect-diagnostics-next-line preferSchemaOverJson:off
Expand All @@ -463,6 +465,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
},
},
automaticGitFetchInterval: 10_000,
defaultRuntimeMode: "approval-required",
});
}).pipe(Effect.provide(makeServerSettingsLayer())),
);
Expand Down
21 changes: 16 additions & 5 deletions apps/web/src/components/ChatView.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ import {
} from "../proposedPlan";
import {
DEFAULT_INTERACTION_MODE,
DEFAULT_RUNTIME_MODE,
DEFAULT_THREAD_TERMINAL_ID,
MAX_TERMINALS_PER_GROUP,
type ChatMessage,
Expand Down Expand Up @@ -141,7 +140,11 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov
import { useSettings } from "../hooks/useSettings";
import { resolveAppModelSelectionForInstance } from "../modelSelection";
import { getTerminalFocusOwner } from "../lib/terminalFocus";
import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
import {
buildNewDraftExecutionDefaults,
resolveNewDraftStartFromOrigin,
} from "../lib/chatThreadActions";
import { getNewThreadRuntimeMode } from "../lib/newThreadSettings";
import {
deriveLogicalProjectKeyFromSettings,
selectProjectGroupingSettings,
Expand Down Expand Up @@ -173,6 +176,7 @@ import { projectEnvironment } from "../state/projects";
import { useEnvironmentQuery } from "../state/query";
import {
primaryServerAvailableEditorsAtom,
primaryServerConfigAtom,
primaryServerKeybindingsAtom,
serverEnvironment,
} from "../state/server";
Expand Down Expand Up @@ -1028,6 +1032,7 @@ function ChatViewContent(props: ChatViewProps) {
routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined,
);
const settings = useSettings();
const serverSettings = useAtomValue(primaryServerConfigAtom)?.settings ?? null;
const setStickyComposerModelSelection = useComposerDraftStore(
(store) => store.setStickyModelSelection,
);
Expand Down Expand Up @@ -1212,7 +1217,8 @@ function ChatViewContent(props: ChatViewProps) {
const threadError = isServerThread
? (localServerError ?? serverThread?.session?.lastError ?? null)
: localDraftError;
const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
const runtimeMode =
composerRuntimeMode ?? activeThread?.runtimeMode ?? settings.defaultRuntimeMode;
const interactionMode =
composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
Expand Down Expand Up @@ -1526,13 +1532,17 @@ function ChatViewContent(props: ChatViewProps) {
return activeDraftSession.threadId;
}

const defaultRuntimeMode = getNewThreadRuntimeMode(serverSettings);
if (!defaultRuntimeMode) {
return null;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}

const nextDraftId = newDraftId();
const nextThreadId = newThreadId();
setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, {
threadId: nextThreadId,
createdAt: new Date().toISOString(),
runtimeMode: DEFAULT_RUNTIME_MODE,
interactionMode: DEFAULT_INTERACTION_MODE,
...buildNewDraftExecutionDefaults(defaultRuntimeMode),
...input,
});
await navigate({
Expand All @@ -1550,6 +1560,7 @@ function ChatViewContent(props: ChatViewProps) {
navigate,
projectGroupingSettings,
routeKind,
serverSettings,
setDraftThreadContext,
setLogicalProjectDraftThreadId,
],
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ProviderDriverKind,
type ProviderInstanceConfig,
type ProviderInstanceId,
type RuntimeMode,
type ScopedThreadRef,
} from "@t3tools/contracts";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
Expand Down Expand Up @@ -108,6 +109,12 @@ const TIMESTAMP_FORMAT_LABELS = {
"24-hour": "24-hour",
} as const;

const RUNTIME_MODE_LABELS: Record<RuntimeMode, string> = {
"approval-required": "Supervised",
"auto-accept-edits": "Auto-accept edits",
"full-access": "Full access",
};

const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");

function withoutProviderInstanceKey<V>(
Expand Down Expand Up @@ -412,6 +419,9 @@ export function useSettingsRestore(onRestored?: () => void) {
DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin
? ["New worktrees start from origin"]
: []),
...(settings.defaultRuntimeMode !== DEFAULT_UNIFIED_SETTINGS.defaultRuntimeMode
? ["Default access mode"]
: []),
...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory
? ["Add project base directory"]
: []),
Expand All @@ -429,6 +439,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.confirmThreadArchive,
settings.confirmThreadDelete,
settings.addProjectBaseDirectory,
settings.defaultRuntimeMode,
settings.defaultThreadEnvMode,
settings.newWorktreesStartFromOrigin,
settings.diffIgnoreWhitespace,
Expand Down Expand Up @@ -462,6 +473,7 @@ export function useSettingsRestore(onRestored?: () => void) {
automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,
defaultRuntimeMode: DEFAULT_UNIFIED_SETTINGS.defaultRuntimeMode,
addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
Expand Down Expand Up @@ -797,6 +809,52 @@ export function GeneralSettingsPanel() {
/>
) : null}

<SettingsRow
title="Default access"
description="Pick the permission mode used when newly created draft threads start."
resetAction={
settings.defaultRuntimeMode !== DEFAULT_UNIFIED_SETTINGS.defaultRuntimeMode ? (
<SettingResetButton
label="default access mode"
onClick={() =>
updateSettings({
defaultRuntimeMode: DEFAULT_UNIFIED_SETTINGS.defaultRuntimeMode,
})
}
/>
) : null
}
control={
<Select
value={settings.defaultRuntimeMode}
onValueChange={(value) => {
if (
value === "approval-required" ||
value === "auto-accept-edits" ||
value === "full-access"
) {
updateSettings({ defaultRuntimeMode: value });
}
}}
>
<SelectTrigger className="w-full sm:w-44" aria-label="Default access mode">
<SelectValue>{RUNTIME_MODE_LABELS[settings.defaultRuntimeMode]}</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem hideIndicator value="approval-required">
{RUNTIME_MODE_LABELS["approval-required"]}
</SelectItem>
<SelectItem hideIndicator value="auto-accept-edits">
{RUNTIME_MODE_LABELS["auto-accept-edits"]}
</SelectItem>
<SelectItem hideIndicator value="full-access">
{RUNTIME_MODE_LABELS["full-access"]}
</SelectItem>
</SelectPopup>
</Select>
}
/>

<SettingsRow
title="Add project starts in"
description='Leave empty to use "~/" when the Add Project browser opens.'
Expand Down
35 changes: 27 additions & 8 deletions apps/web/src/hooks/useHandleNewThread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import {
scopeProjectRef,
scopeThreadRef,
} from "@t3tools/client-runtime/environment";
import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts";
import { type ScopedProjectRef } from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import { useParams, useRouter } from "@tanstack/react-router";
import { useCallback, useMemo } from "react";
import {
Expand All @@ -20,8 +21,13 @@ import {
selectProjectGroupingSettings,
} from "../logicalProject";
import { readThreadShell, useProjects, useThread } from "../state/entities";
import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
import {
buildNewDraftExecutionDefaults,
resolveNewDraftStartFromOrigin,
} from "../lib/chatThreadActions";
import { getNewThreadRuntimeMode } from "../lib/newThreadSettings";
import { resolveThreadRouteTarget } from "../threadRoutes";
import { primaryServerConfigAtom } from "../state/server";
import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
import { useSettings } from "./useSettings";

Expand All @@ -31,6 +37,7 @@ export function useNewThreadHandler() {
const newWorktreesStartFromOrigin = useSettings(
(settings) => settings.newWorktreesStartFromOrigin,
);
const serverSettings = useAtomValue(primaryServerConfigAtom)?.settings ?? null;
const router = useRouter();
const getCurrentRouteTarget = useCallback(() => {
const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
Expand Down Expand Up @@ -152,11 +159,16 @@ export function useNewThreadHandler() {
return Promise.resolve();
}

const draftId = newDraftId();
const threadId = newThreadId();
const createdAt = new Date().toISOString();
const initialEnvMode = options?.envMode ?? "local";
return (async () => {
const defaultRuntimeMode = getNewThreadRuntimeMode(serverSettings);
if (!defaultRuntimeMode) {
return;
}

const draftId = newDraftId();
const threadId = newThreadId();
const createdAt = new Date().toISOString();
const initialEnvMode = options?.envMode ?? "local";
setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {
threadId,
createdAt,
Expand All @@ -169,7 +181,7 @@ export function useNewThreadHandler() {
envMode: initialEnvMode,
newWorktreesStartFromOrigin,
}),
runtimeMode: DEFAULT_RUNTIME_MODE,
...buildNewDraftExecutionDefaults(defaultRuntimeMode),
});
applyStickyState(draftId);

Expand All @@ -179,7 +191,14 @@ export function useNewThreadHandler() {
});
})();
},
[newWorktreesStartFromOrigin, getCurrentRouteTarget, projectGroupingSettings, router, projects],
[
newWorktreesStartFromOrigin,
getCurrentRouteTarget,
projectGroupingSettings,
router,
projects,
serverSettings,
],
);
}

Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/lib/chatThreadActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment";
import { EnvironmentId, ProjectId } from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";
import {
resolveThreadActionProjectRef,
buildNewDraftExecutionDefaults,
resolveNewDraftStartFromOrigin,
resolveThreadActionProjectRef,
startNewLocalThreadFromContext,
startNewThreadFromContext,
type ChatThreadActionContext,
Expand All @@ -25,6 +26,13 @@ function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatTh
}

describe("chatThreadActions", () => {
it("initializes new drafts with the configured default access mode", () => {
expect(buildNewDraftExecutionDefaults("approval-required")).toEqual({
runtimeMode: "approval-required",
interactionMode: "default",
});
});

it("only applies the start-from-origin default to new worktree drafts", () => {
expect(
resolveNewDraftStartFromOrigin({
Expand Down
19 changes: 18 additions & 1 deletion apps/web/src/lib/chatThreadActions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { scopeProjectRef } from "@t3tools/client-runtime/environment";
import type { EnvironmentId, ProjectId, ScopedProjectRef } from "@t3tools/contracts";
import {
DEFAULT_PROVIDER_INTERACTION_MODE,
type EnvironmentId,
type ProjectId,
type ProviderInteractionMode,
type RuntimeMode,
type ScopedProjectRef,
} from "@t3tools/contracts";
import type { DraftThreadEnvMode } from "../composerDraftStore";

interface ThreadContextLike {
Expand Down Expand Up @@ -43,6 +50,16 @@ export function resolveNewDraftStartFromOrigin(input: {
return input.envMode === "worktree" && input.newWorktreesStartFromOrigin;
}

export function buildNewDraftExecutionDefaults(defaultRuntimeMode: RuntimeMode): {
runtimeMode: RuntimeMode;
interactionMode: ProviderInteractionMode;
} {
return {
runtimeMode: defaultRuntimeMode,
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
};
}

export function resolveThreadActionProjectRef(
context: ChatThreadActionContext,
): ScopedProjectRef | null {
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/lib/newThreadSettings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import { toastManager } from "../components/ui/toast";
import { getNewThreadRuntimeMode } from "./newThreadSettings";

afterEach(() => {
vi.restoreAllMocks();
});

describe("getNewThreadRuntimeMode", () => {
it("returns the server-configured default runtime mode", () => {
expect(
getNewThreadRuntimeMode({
...DEFAULT_SERVER_SETTINGS,
defaultRuntimeMode: "approval-required",
}),
).toBe("approval-required");
});

it("blocks thread creation until server settings are available", () => {
const addToast = vi.spyOn(toastManager, "add").mockImplementation(() => "toast-1");

expect(getNewThreadRuntimeMode(null)).toBeNull();
expect(addToast).toHaveBeenCalledWith(
expect.objectContaining({ title: "Could not create thread" }),
);
});
});
18 changes: 18 additions & 0 deletions apps/web/src/lib/newThreadSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { RuntimeMode, ServerSettings } from "@t3tools/contracts";

import { stackedThreadToast, toastManager } from "../components/ui/toast";

export function getNewThreadRuntimeMode(serverSettings: ServerSettings | null): RuntimeMode | null {
if (serverSettings) {
return serverSettings.defaultRuntimeMode;
}

toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not create thread",
description: "Server settings are not available yet. No thread was created.",
}),
);
return null;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
Loading
Loading