Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
15 changes: 9 additions & 6 deletions apps/web/src/components/settings/ProjectDefaultsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import { TraitsPicker } from "../chat/TraitsPicker";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
import { toastManager } from "../ui/toast";
import { Switch } from "../ui/switch";
import type { ProjectSettingsCategory } from "./ProjectSettingsPanel";
import { searchableSetting } from "./settingsSearch";
import { useSettingsScope } from "./SettingsScopeContext";
import {
Expand All @@ -41,16 +40,20 @@ import {
useUpdateScopedSettings,
} from "./useScopedSettings";

/**
* Rows for the settings a project may override. The same rows edit
* environment defaults at an environment scope and project overrides at a
* project or checkout scope; the scoped hooks route the write.
*/
const WORKTREE_SUBMODULES_OPTIONS = ["recursive", "top-level", "none"] as const;
function isWorktreeSubmodules(value: string | null): value is WorktreeSubmodules {
return value !== null && (WORKTREE_SUBMODULES_OPTIONS as readonly string[]).includes(value);
}

export type ProjectSettingsCategory = "general" | "integrations" | "source-control";

/**
* Rows for the settings a project may override. The same rows edit
* environment defaults at an environment scope and project overrides at a
* project or checkout scope; the scoped hooks route the write. The Project
* page renders the "general" rows too, so a project's new-thread defaults sit
* beside its name.
*/
export function ProjectDefaultsSettings({ category }: { category: ProjectSettingsCategory }) {
const { scope, target, targets, connectedEnvironments } = useSettingsScope();
const settings = useScopedSettings();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { DEFAULT_SERVER_SETTINGS, ProjectId } from "@t3tools/contracts";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { describe, expect, it } from "vite-plus/test";

import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
import { listedProjectOverrides, projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";

describe("projectGroupTitleNeedsUpdate", () => {
it("updates divergent member titles even when the next title is the derived group label", () => {
Expand All @@ -21,3 +23,39 @@ describe("projectGroupTitleNeedsUpdate", () => {
);
});
});

describe("listedProjectOverrides", () => {
const projectId = ProjectId.make("project");
const target = (overrides: object) =>
resolveProjectSettings(
{
...DEFAULT_SERVER_SETTINGS,
projectSettingsOverrides: { [projectId]: overrides },
},
projectId,
);

it("lists overrides edited elsewhere in sidebar order, skipping rows the page already shows", () => {
const listed = listedProjectOverrides([
target({
pullRequestMergeMethod: "squash",
defaultModelSelection: null,
worktreeCleanup: { mode: "off" },
enableAgentBrowserAccess: true,
defaultProjectScripts: [],
}),
]);
expect(listed.map(({ key, item }) => [key, item.to])).toEqual([
["enableAgentBrowserAccess", "/settings/integrations"],
["pullRequestMergeMethod", "/settings/source-control"],
["worktreeCleanup", "/settings/storage"],
]);
});

it("lists a key overridden on any selected checkout", () => {
expect(
listedProjectOverrides([target({}), target({ defaultAutoPull: true })]).map(({ key }) => key),
).toEqual(["defaultAutoPull"]);
expect(listedProjectOverrides([target({})])).toEqual([]);
});
});
55 changes: 55 additions & 0 deletions apps/web/src/components/settings/ProjectSettingsPanel.logic.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,62 @@
import {
PROJECT_SCOPED_SERVER_SETTING_KEYS,
type ProjectScopedServerSettingKey,
} from "@t3tools/contracts";

import type { ScopedSettingsTarget } from "./scopedSettings";
import {
SETTINGS_SECTION_LABELS,
settingsSearchItem,
type SettingsSearchItemId,
} from "./settingsSearch";

export function projectGroupTitleNeedsUpdate(
memberTitles: ReadonlyArray<string>,
nextTitle: string,
wasEdited: boolean,
): boolean {
return wasEdited && memberTitles.some((title) => title !== nextTitle);
}

/**
* The row that edits each project-overridable setting. `null` keys already
* have a control on the Project page (New threads, Actions). A new scopable
* key fails the build until it has an entry here.
*/
const PROJECT_OVERRIDE_ROWS = {
worktreeCleanup: "storage-worktrees",
defaultModelSelection: null,
defaultRuntimeMode: null,
defaultThreadEnvMode: null,
newWorktreesStartFromOrigin: "start-from-origin",
worktreeSubmodules: null,
defaultAutoPull: "automatic-pull",
defaultProjectScripts: null,
enableAgentBrowserAccess: "agent-browser-access",
enableAgentDeviceAccess: "agent-device-access",
textGenerationModelSelection: "text-generation-model",
sourceControlWriterModelSelection: "source-control-writer-model",
sourceControlWritingStyle: "source-control-writing-style",
pullRequestMergeMethod: "pull-request-merge-method",
sidebarAutoSettleOnMerge: "auto-settle-merged-threads",
sidebarAutoSettleAfterDays: "auto-settle-inactive-threads",
continueThreadsAfterServerUpdate: "continue-threads-after-server-update",
responseStreamingMode: "response-streaming",
} as const satisfies Record<ProjectScopedServerSettingKey, SettingsSearchItemId | null>;

const SECTION_ORDER: readonly string[] = Object.keys(SETTINGS_SECTION_LABELS);

/**
* Settings the Project page lists under Overrides: overridden on at least one
* selected checkout and edited on another page. Sorted in sidebar order.
*/
export function listedProjectOverrides(targets: readonly Pick<ScopedSettingsTarget, "sources">[]) {
return PROJECT_SCOPED_SERVER_SETTING_KEYS.flatMap((key) => {
const itemId = PROJECT_OVERRIDE_ROWS[key];
return itemId !== null && targets.some((target) => target.sources[key] === "project")
Comment thread
t3dotgg marked this conversation as resolved.
? [{ key, item: settingsSearchItem(itemId) }]
: [];
}).toSorted(
(left, right) => SECTION_ORDER.indexOf(left.item.to) - SECTION_ORDER.indexOf(right.item.to),
);
}
62 changes: 57 additions & 5 deletions apps/web/src/components/settings/ProjectSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { AsyncResult } from "effect/unstable/reactivity";
import { type EnvironmentId, type ProjectIconOverride } from "@t3tools/contracts";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import * as Cause from "effect/Cause";
import { Trash2Icon } from "lucide-react";
import { ChevronRightIcon, Trash2Icon } from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";

import { useComposerDraftStore } from "../../composerDraftStore";
Expand Down Expand Up @@ -39,7 +39,12 @@ import {
ProjectFaviconPickerDialog,
} from "./ProjectFaviconPickerDialog";
import { ProjectActionsSettings } from "./ProjectActionsSettings";
import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
import { listedProjectOverrides, projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
import { formatSettingValue } from "./SettingInheritance";
import { scopedSettingsAreMixed } from "./scopedSettings";
import { useSettingsScope } from "./SettingsScopeContext";
import { SETTINGS_SECTION_LABELS } from "./settingsSearch";
import { useSettingsProjectGroups } from "./useSettingsProjectGroups";

const ProjectIconPickerDialog = lazy(() =>
Expand All @@ -52,8 +57,6 @@ function memberKey(member: { environmentId: string; id: string }): string {
return `${member.environmentId}:${member.id}`;
}

export type ProjectSettingsCategory = "general" | "integrations" | "source-control";

export function ProjectSettingsPanel({
projectKey,
environmentId = null,
Expand Down Expand Up @@ -480,6 +483,8 @@ function ProjectDetail({
}
/>
</SettingsSection>
<ProjectDefaultsSettings category="general" />
<ProjectOverridesSection />
<ProjectActionsSettings />
{hasMultipleCheckouts ? checkoutChoices : null}
<SettingsSection title="Danger">
Expand Down Expand Up @@ -542,3 +547,50 @@ function ProjectDetail({
</>
);
}

/**
* Settings this project overrides that are edited on other pages. Each row
* shows the value, resets the override, and links to the row that edits it.
*/
function ProjectOverridesSection() {
const { targets, target } = useSettingsScope();
const overrides = listedProjectOverrides(targets);
if (overrides.length === 0 || target === null) return null;
return (
<SettingsSection id="project-overrides" title="Overrides">
{overrides.map(({ key, item }) => {
const page = SETTINGS_SECTION_LABELS[item.to];
const value = scopedSettingsAreMixed(targets, [key])
? "Mixed"
: formatSettingValue(key, target.settings[key]);
return (
<SettingsRow
key={key}
serverScoped
settingKeys={[key]}
title={item.title}
description={page}
control={
<Button
size="sm"
variant="outline"
aria-label={`${item.title}: ${value}. Open in ${page}`}
render={
<Link
to={item.to}
hash={item.targetId ?? item.id}
hashScrollIntoView={false}
state={{ settingsTargetHighlight: true }}
/>
}
>
{value}
<ChevronRightIcon />
</Button>
}
/>
);
})}
</SettingsSection>
);
}
4 changes: 2 additions & 2 deletions apps/web/src/components/settings/ProjectsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ProjectSettingsPanel } from "./ProjectSettingsPanel";
import { useSettingsScope } from "./SettingsScopeContext";
import { SettingsScopeNotice } from "./SettingsScopeNotice";

/** Project identity and checkout management for the selected project. */
/** Identity, new-thread defaults, overrides, actions and checkouts for the selected project. */
export function ProjectsSettings() {
const { search: value, scope } = useSettingsScope();
// The panel follows remembered members when grouping replaces a project key.
Expand All @@ -25,7 +25,7 @@ export function ProjectsSettings() {
<p className="p-8 text-sm text-muted-foreground">{scope.message}</p>
) : (
<SettingsScopeNotice target="project">
Choose a project to manage its name, icon, checkouts and actions.
Choose a project to manage its name, icon, defaults, actions and checkouts.
</SettingsScopeNotice>
)}
</div>
Expand Down
23 changes: 16 additions & 7 deletions apps/web/src/components/settings/SettingInheritance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ import { cn } from "../../lib/utils";
import type { EnvironmentPresentation } from "../../state/environments";
import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic";
import { runtimeModeConfig } from "../chat/runtimeModeConfig";
import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
import { Button, InlineButton } from "../ui/button";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import type { ProjectOverrideEntry, ScopedSettingsTarget } from "./scopedSettings";
import { isProjectScopedSettingKey } from "./scopedSettings";
import { RESPONSE_STREAMING_MODE_LABELS } from "./SettingsPanels.logic";

interface InheritanceLayer {
readonly key: "project" | "environment" | "t3.json" | "built-in";
Expand All @@ -34,8 +36,8 @@ const WRITING_STYLE_LABELS: Record<string, string> = {
custom: "Custom instructions",
};

/** Human labels for the values the chain can show; falls back to a type summary. */
function formatValue(key: keyof ServerSettings, value: unknown): string {
/** Human labels for setting values in the chain and override lists; falls back to a type summary. */
export function formatSettingValue(key: keyof ServerSettings, value: unknown): string {
if (value === null || value === undefined) {
return key === "pullRequestMergeMethod"
? "Last selected"
Expand All @@ -62,6 +64,12 @@ function formatValue(key: keyof ServerSettings, value: unknown): string {
if (key === "worktreeSubmodules" && value in WORKTREE_SUBMODULES_LABELS) {
return WORKTREE_SUBMODULES_LABELS[value as WorktreeSubmodules];
}
if (key === "defaultRuntimeMode" && value in runtimeModeConfig) {
return runtimeModeConfig[value as keyof typeof runtimeModeConfig].label;
}
if (key === "responseStreamingMode" && value in RESPONSE_STREAMING_MODE_LABELS) {
return RESPONSE_STREAMING_MODE_LABELS[value as keyof typeof RESPONSE_STREAMING_MODE_LABELS];
}
if (key === "pullRequestMergeMethod" && value in PULL_REQUEST_MERGE_METHOD_LABELS) {
return PULL_REQUEST_MERGE_METHOD_LABELS[
value as keyof typeof PULL_REQUEST_MERGE_METHOD_LABELS
Expand All @@ -73,6 +81,7 @@ function formatValue(key: keyof ServerSettings, value: unknown): string {
if (typeof value === "object") {
if ("model" in value && typeof value.model === "string") return value.model;
if ("mode" in value && typeof value.mode === "string") {
if (key === "worktreeCleanup") return value.mode === "off" ? "Off" : "Custom";
return WRITING_STYLE_LABELS[value.mode] ?? value.mode;
}
}
Expand All @@ -99,23 +108,23 @@ export function settingInheritanceLayers(
layers.push({
key: "project",
label: "Project",
value: source === "project" ? formatValue(key, target.settings[key]) : "Inherits",
value: source === "project" ? formatSettingValue(key, target.settings[key]) : "Inherits",
effective: source === "project",
set: source === "project",
});
}
layers.push({
key: "environment",
label: target.label,
value: environmentSet ? formatValue(key, environmentValue) : "Inherits",
value: environmentSet ? formatSettingValue(key, environmentValue) : "Inherits",
effective: source === "environment" && environmentSet,
set: environmentSet,
});
if (fileBacked && target.projectId !== null) {
layers.push({
key: "t3.json",
label: "t3.json",
value: source === "t3.json" ? formatValue(key, target.settings[key]) : "Inherits",
value: source === "t3.json" ? formatSettingValue(key, target.settings[key]) : "Inherits",
effective: source === "t3.json",
set: source === "t3.json",
});
Expand All @@ -128,7 +137,7 @@ export function settingInheritanceLayers(
layers.push({
key: "built-in",
label: "Default",
value: formatValue(key, builtIn),
value: formatSettingValue(key, builtIn),
effective: source === "environment" && !environmentSet,
set: true,
});
Expand Down Expand Up @@ -302,7 +311,7 @@ export function SettingInheritance({
</InlineButton>
<span className="max-w-32 truncate text-muted-foreground tabular-nums">
{isProjectScopedSettingKey(key)
? formatValue(key, overrides[project.projectId]?.[key])
? formatSettingValue(key, overrides[project.projectId]?.[key])
: null}
</span>
</li>
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ProviderInstanceConfig,
PreviewViewportSetting,
ProviderInstanceId,
ResponseStreamingMode,
ServerSettings,
SidebarProjectGroupingMode,
UnifiedSettings,
Expand All @@ -19,6 +20,12 @@ import {
import * as Duration from "effect/Duration";
import * as Equal from "effect/Equal";

export const RESPONSE_STREAMING_MODE_LABELS: Record<ResponseStreamingMode, string> = {
turn: "Wait for the full response",
paragraph: "Show finished paragraphs",
token: "Token by token (legacy)",
};

export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean {
return mode !== "separate";
}
Expand Down
7 changes: 1 addition & 6 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ import {
readLastEnabledProjectGroupingMode,
rememberEnabledProjectGroupingMode,
resolveBackgroundActivityProfileOption,
RESPONSE_STREAMING_MODE_LABELS,
} from "./SettingsPanels.logic";
import {
PolicyTooltip,
Expand All @@ -178,12 +179,6 @@ const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, s
none: "None",
};

const RESPONSE_STREAMING_MODE_LABELS: Record<ResponseStreamingMode, string> = {
turn: "Wait for the full response",
paragraph: "Show finished paragraphs",
token: "Token by token (legacy)",
};

const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record<ResponseStreamingMode, string> = {
turn: "Text appears once the agent finishes its turn.",
paragraph: "Each paragraph or code block appears as soon as it is complete.",
Expand Down
Loading
Loading