From 79afb304d745ed1b0957b8bfa653f17e55445111 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 22 Sep 2026 16:08:51 -0700 Subject: [PATCH] feat(web): show new-thread defaults and overrides on the Project page The Project page only held name, icon, actions and removal, so a project's default model had no obvious home. It now renders the same New threads rows as General and lists every other setting the project overrides, each linking to the row that edits it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../settings/ProjectDefaultsSettings.tsx | 15 +++-- .../ProjectSettingsPanel.logic.test.ts | 40 +++++++++++- .../settings/ProjectSettingsPanel.logic.ts | 55 ++++++++++++++++ .../settings/ProjectSettingsPanel.tsx | 62 +++++++++++++++++-- .../components/settings/ProjectsSettings.tsx | 4 +- .../settings/SettingInheritance.tsx | 23 ++++--- .../settings/SettingsPanels.logic.ts | 7 +++ .../components/settings/SettingsPanels.tsx | 7 +-- .../src/components/settings/settingsSearch.ts | 9 ++- docs/user/project-settings.md | 10 +-- 10 files changed, 200 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index fe7576933857..e52df8a69e78 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -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 { @@ -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(); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts index 8a72b3510ceb..1dd62809a71b 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts @@ -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", () => { @@ -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([]); + }); +}); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts index 17ff824099fb..ac852662ecbc 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts @@ -1,3 +1,15 @@ +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, nextTitle: string, @@ -5,3 +17,46 @@ export function projectGroupTitleNeedsUpdate( ): 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; + +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[]) { + return PROJECT_SCOPED_SERVER_SETTING_KEYS.flatMap((key) => { + const itemId = PROJECT_OVERRIDE_ROWS[key]; + return itemId !== null && targets.some((target) => target.sources[key] === "project") + ? [{ key, item: settingsSearchItem(itemId) }] + : []; + }).toSorted( + (left, right) => SECTION_ORDER.indexOf(left.item.to) - SECTION_ORDER.indexOf(right.item.to), + ); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index c31edf0f7e3d..0c1f2a628966 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -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"; @@ -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(() => @@ -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, @@ -480,6 +483,8 @@ function ProjectDetail({ } /> + + {hasMultipleCheckouts ? checkoutChoices : null} @@ -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 ( + + {overrides.map(({ key, item }) => { + const page = SETTINGS_SECTION_LABELS[item.to]; + const value = scopedSettingsAreMixed(targets, [key]) + ? "Mixed" + : formatSettingValue(key, target.settings[key]); + return ( + + } + > + {value} + + + } + /> + ); + })} + + ); +} diff --git a/apps/web/src/components/settings/ProjectsSettings.tsx b/apps/web/src/components/settings/ProjectsSettings.tsx index 995b11191179..9c8df89ca280 100644 --- a/apps/web/src/components/settings/ProjectsSettings.tsx +++ b/apps/web/src/components/settings/ProjectsSettings.tsx @@ -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. @@ -25,7 +25,7 @@ export function ProjectsSettings() {

{scope.message}

) : ( - Choose a project to manage its name, icon, checkouts and actions. + Choose a project to manage its name, icon, defaults, actions and checkouts. )} diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx index da524448f0b2..af8406db6ec4 100644 --- a/apps/web/src/components/settings/SettingInheritance.tsx +++ b/apps/web/src/components/settings/SettingInheritance.tsx @@ -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"; @@ -34,8 +36,8 @@ const WRITING_STYLE_LABELS: Record = { 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" @@ -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 @@ -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; } } @@ -99,7 +108,7 @@ 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", }); @@ -107,7 +116,7 @@ export function settingInheritanceLayers( 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, }); @@ -115,7 +124,7 @@ export function settingInheritanceLayers( 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", }); @@ -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, }); @@ -302,7 +311,7 @@ export function SettingInheritance({ {isProjectScopedSettingKey(key) - ? formatValue(key, overrides[project.projectId]?.[key]) + ? formatSettingValue(key, overrides[project.projectId]?.[key]) : null} diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index d5c6359c1c9f..59bd44219896 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -5,6 +5,7 @@ import type { ProviderInstanceConfig, PreviewViewportSetting, ProviderInstanceId, + ResponseStreamingMode, ServerSettings, SidebarProjectGroupingMode, UnifiedSettings, @@ -19,6 +20,12 @@ import { import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; +export const RESPONSE_STREAMING_MODE_LABELS: Record = { + turn: "Wait for the full response", + paragraph: "Show finished paragraphs", + token: "Token by token (legacy)", +}; + export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { return mode !== "separate"; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6aeeb269f5a0..1df6b5ef81fd 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -157,6 +157,7 @@ import { readLastEnabledProjectGroupingMode, rememberEnabledProjectGroupingMode, resolveBackgroundActivityProfileOption, + RESPONSE_STREAMING_MODE_LABELS, } from "./SettingsPanels.logic"; import { PolicyTooltip, @@ -178,12 +179,6 @@ const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { - turn: "Wait for the full response", - paragraph: "Show finished paragraphs", - token: "Token by token (legacy)", -}; - const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record = { turn: "Text appears once the agent finishes its turn.", paragraph: "Each paragraph or code block appears as soon as it is complete.", diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index acc9b7eb7dbe..83fa0fbefb05 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -687,6 +687,7 @@ export const SETTINGS_SEARCH_ITEMS = [ "repository conventions conventional commits custom instructions change descriptions request titles", ], environmentOnly: true, + scope: "project-defaults", }, { id: "follow-change-request-templates", @@ -694,6 +695,7 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/source-control", searchTerms: ["repository pr pull request description structure"], environmentOnly: true, + scope: "project-defaults", }, { id: "source-control-writer-model", @@ -930,10 +932,15 @@ export function searchableSetting(id: SettingsSearchItemId): { readonly id: string; readonly title: string; } { - const { id: anchorId, title } = SEARCH_ITEMS_BY_ID.get(id)!; + const { id: anchorId, title } = settingsSearchItem(id); return { id: anchorId, title }; } +/** The catalog entry for `id`, for links that open a setting's row on its page. */ +export function settingsSearchItem(id: SettingsSearchItemId): SettingsSearchItem { + return SEARCH_ITEMS_BY_ID.get(id)!; +} + export function filterAvailableSettingsSearchItems( availability: SettingsSearchAvailability, ): ReadonlyArray { diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index b5ad7c6514d1..fdbc43b05668 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -44,10 +44,12 @@ access. Source Control contains automatic pull, the default pull request merge m generation. The same rows edit environment defaults or project overrides depending on the project crumb. -The Project category, shown while a project is selected, holds the project's name, icon, actions, -checkouts and removal. Actions belong to a project: editing them creates the project's own list -on each selected environment, and reset returns to the environment's shared list. A project's -`t3.json` actions can be imported there. +The Project category, shown while a project is selected, holds the project's name, icon, the +model and workspace for its new threads, actions, checkouts and removal. **Overrides** lists every +other setting the project overrides, with its value; click one to open it on its page. Actions +belong to a project: editing them creates the project's own list on each selected environment, +and reset returns to the environment's shared list. A project's `t3.json` actions can be +imported there. Settings a repository can also declare in `t3.json`, such as the workspace for new threads, resolve in one order: a project override, then the environment setting, then `t3.json`, then the