| author | |
| committer | |
| log | 72fbad7b1ad303a5e0a896e1de5c6489f8d10fc8 |
| tree | c486c195088d4be0d7932bf5b0c4f2db7aa8b2f3 |
| parent | 0f36a1176921691440e4c5b790c33f439faab98c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>6 files changed, 143 insertions(+), 12 deletions(-)
apps/mobile/src/features/threads/new-task-flow-provider.tsx+16-3| ... | ... | @@ -24,7 +24,7 @@ import { |
| 24 | 24 | ThreadId, |
| 25 | 25 | } from "@t3tools/contracts"; |
| 26 | 26 | import { sanitizeNewRefName } from "@t3tools/shared/git"; |
| 27 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | |
| 27 | import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings"; | |
| 28 | 28 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; |
| 29 | 29 | import * as Arr from "effect/Array"; |
| 30 | 30 | import { pipe } from "effect/Function"; |
| ... | ... | @@ -487,7 +487,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 487 | 487 | const attachments = selectedProjectDraft.attachments; |
| 488 | 488 | // Default mode until the user picks one explicitly — same resolution web |
| 489 | 489 | // uses for new draft threads: per-project setting, then the repo's |
| 490 | // checked-in t3.json, then the server's configured default. | |
| 490 | // checked-in t3.json, then the server's configured default (which a new | |
| 491 | // project with no threads yet replaces with its checkout). | |
| 491 | 492 | const fileAccessSession = useEnvironmentQuery( |
| 492 | 493 | selectedProject !== null && selectedProject.workspaceRoot !== "" |
| 493 | 494 | ? environmentSession.sessionStateAtom(selectedProject.environmentId) |
| ... | ... | @@ -535,8 +536,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 535 | 536 | // A thread without a project runs in a plain folder, so worktree mode |
| 536 | 537 | // would leave it unsendable: it is always local and offers no choice. |
| 537 | 538 | const canChooseWorkspace = !isScratchDraft; |
| 539 | const selectedProjectHasThreads = | |
| 540 | selectedProject !== null && | |
| 541 | threads.some( | |
| 542 | (thread) => | |
| 543 | thread.environmentId === selectedProject.environmentId && | |
| 544 | thread.projectId === selectedProject.id, | |
| 545 | ); | |
| 538 | 546 | const defaultWorkspaceMode: WorkspaceMode = canChooseWorkspace |
| 539 | ? projectSettings.settings.defaultThreadEnvMode | |
| 547 | ? resolveNewThreadEnvMode({ | |
| 548 | projectSettings, | |
| 549 | workspaceRoot: selectedProject?.workspaceRoot ?? null, | |
| 550 | newProjectsRoot: selectedEnvironmentServerConfig?.newProjectsRoot, | |
| 551 | projectHasThreads: selectedProjectHasThreads, | |
| 552 | }) | |
| 540 | 553 | : "local"; |
| 541 | 554 | // While the file read is pending and nothing above it decided, the |
| 542 | 555 | // resolved default is provisional. Nothing may write it into the draft |
apps/web/src/hooks/useHandleNewThread.test.ts+5| ... | ... | @@ -118,6 +118,10 @@ vi.mock("@t3tools/shared/projectSettings", () => ({ |
| 118 | 118 | sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" }, |
| 119 | 119 | overrides: {}, |
| 120 | 120 | }), |
| 121 | // The new-project rule has its own tests in projectSettings.test.ts. | |
| 122 | resolveNewThreadEnvMode: (input: { | |
| 123 | projectSettings: { settings: { defaultThreadEnvMode: "local" | "worktree" } }; | |
| 124 | }) => input.projectSettings.settings.defaultThreadEnvMode, | |
| 121 | 125 | })); |
| 122 | 126 | vi.mock("@tanstack/react-router", () => ({ |
| 123 | 127 | useParams: () => null, |
| ... | ... | @@ -166,6 +170,7 @@ vi.mock("../state/entities", () => ({ |
| 166 | 170 | }, |
| 167 | 171 | ], |
| 168 | 172 | readThreadShell: () => null, |
| 173 | readThreadShells: () => [], | |
| 169 | 174 | useProjects: () => [], |
| 170 | 175 | useThread: () => null, |
| 171 | 176 | })); |
apps/web/src/hooks/useHandleNewThread.ts+23-8| ... | ... | @@ -22,8 +22,14 @@ import { |
| 22 | 22 | getProjectOrderKey, |
| 23 | 23 | selectProjectGroupingSettings, |
| 24 | 24 | } from "../logicalProject"; |
| 25 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | |
| 26 | import { readProjects, readThreadShell, useProjects, useThreadShell } from "../state/entities"; | |
| 25 | import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings"; | |
| 26 | import { | |
| 27 | readProjects, | |
| 28 | readThreadShell, | |
| 29 | readThreadShells, | |
| 30 | useProjects, | |
| 31 | useThreadShell, | |
| 32 | } from "../state/entities"; | |
| 27 | 33 | import { |
| 28 | 34 | hasExplicitComposerModelSelection, |
| 29 | 35 | resolveNewDraftStartFromOrigin, |
| ... | ... | @@ -154,12 +160,21 @@ export function useNewThreadHandler() { |
| 154 | 160 | const projectFile = consultProjectFile |
| 155 | 161 | ? await readT3ProjectFile(project.environmentId, project.workspaceRoot) |
| 156 | 162 | : null; |
| 157 | return resolveProjectSettings( | |
| 158 | targetServerSettings, | |
| 159 | project?.id ?? null, | |
| 160 | project, | |
| 161 | projectFile, | |
| 162 | ).settings.defaultThreadEnvMode; | |
| 163 | return resolveNewThreadEnvMode({ | |
| 164 | projectSettings: resolveProjectSettings( | |
| 165 | targetServerSettings, | |
| 166 | project?.id ?? null, | |
| 167 | project, | |
| 168 | projectFile, | |
| 169 | ), | |
| 170 | workspaceRoot: project?.workspaceRoot ?? null, | |
| 171 | newProjectsRoot: environmentServerConfigs.get(projectRef.environmentId)?.newProjectsRoot, | |
| 172 | projectHasThreads: readThreadShells().some( | |
| 173 | (thread) => | |
| 174 | thread.environmentId === projectRef.environmentId && | |
| 175 | thread.projectId === projectRef.projectId, | |
| 176 | ), | |
| 177 | }); | |
| 163 | 178 | }; |
| 164 | 179 | const logicalProjectKey = project |
| 165 | 180 | ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) |
docs/user/source-control.md+2-1| ... | ... | @@ -119,7 +119,8 @@ Merging a stack lands it through GitCafe, and updating a branch restacks the lay |
| 119 | 119 | To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or |
| 120 | 120 | **New project** under **Add Project** on any client, and type a name. T3 Code makes a Git |
| 121 | 121 | repository in `~/.t3/projects` (the `projects` folder of your T3 data directory) with a README, |
| 122 | an icon, and a first commit, then opens a new thread in it. The folder is named after the project, | |
| 122 | an icon, and a first commit, then opens a new thread in it. Until the project has a thread, new | |
| 123 | threads work in its folder, even when your default is a new worktree. The folder is named after the project, | |
| 123 | 124 | like `pinball-stats` for "Pinball Stats". Turn on **Create private repository on GitHub** to also |
| 124 | 125 | publish it. If Git has no name or email on that machine, the project is created without the |
| 125 | 126 | first commit. |
packages/shared/src/projectSettings.test.ts+60| ... | ... | @@ -10,6 +10,7 @@ import { createModelSelection } from "./model.ts"; |
| 10 | 10 | import { |
| 11 | 11 | clearProjectSettingsOverrides, |
| 12 | 12 | hasProjectSettingsOverrides, |
| 13 | resolveNewThreadEnvMode, | |
| 13 | 14 | resolveProjectFileBackedSetting, |
| 14 | 15 | resolveProjectSettings, |
| 15 | 16 | resolveWorktreeCleanup, |
| ... | ... | @@ -223,6 +224,65 @@ describe("resolveProjectSettings with a t3.json", () => { |
| 223 | 224 | }); |
| 224 | 225 | }); |
| 225 | 226 | |
| 227 | describe("resolveNewThreadEnvMode", () => { | |
| 228 | const worktreeEnvironment = resolveProjectSettings( | |
| 229 | { ...DEFAULT_SERVER_SETTINGS, defaultThreadEnvMode: "worktree" }, | |
| 230 | projectId, | |
| 231 | null, | |
| 232 | null, | |
| 233 | ); | |
| 234 | const newProject = { | |
| 235 | projectSettings: worktreeEnvironment, | |
| 236 | workspaceRoot: "/data/projects/pinball-stats", | |
| 237 | newProjectsRoot: "/data/projects/", | |
| 238 | projectHasThreads: false, | |
| 239 | }; | |
| 240 | ||
| 241 | it("starts a new project's first thread in its checkout", () => { | |
| 242 | expect(resolveNewThreadEnvMode(newProject)).toBe("local"); | |
| 243 | expect( | |
| 244 | resolveNewThreadEnvMode({ | |
| 245 | ...newProject, | |
| 246 | workspaceRoot: "C:\\Data\\Projects\\Pinball-Stats", | |
| 247 | newProjectsRoot: "c:/data/projects", | |
| 248 | }), | |
| 249 | ).toBe("local"); | |
| 250 | }); | |
| 251 | ||
| 252 | it("keeps the configured default once the project has a thread or is not a new project", () => { | |
| 253 | expect(resolveNewThreadEnvMode({ ...newProject, projectHasThreads: true })).toBe("worktree"); | |
| 254 | expect(resolveNewThreadEnvMode({ ...newProject, workspaceRoot: "/data/projects" })).toBe( | |
| 255 | "worktree", | |
| 256 | ); | |
| 257 | expect( | |
| 258 | resolveNewThreadEnvMode({ ...newProject, workspaceRoot: "/data/projects/a/nested" }), | |
| 259 | ).toBe("worktree"); | |
| 260 | expect(resolveNewThreadEnvMode({ ...newProject, workspaceRoot: "/data/projects-old/a" })).toBe( | |
| 261 | "worktree", | |
| 262 | ); | |
| 263 | expect(resolveNewThreadEnvMode({ ...newProject, newProjectsRoot: undefined })).toBe("worktree"); | |
| 264 | }); | |
| 265 | ||
| 266 | it("lets a project override, or a t3.json the environment defers to, keep worktree mode", () => { | |
| 267 | for (const projectSettings of [ | |
| 268 | resolveProjectSettings( | |
| 269 | { | |
| 270 | ...DEFAULT_SERVER_SETTINGS, | |
| 271 | projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "worktree" } }, | |
| 272 | }, | |
| 273 | projectId, | |
| 274 | null, | |
| 275 | null, | |
| 276 | ), | |
| 277 | resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, { | |
| 278 | defaultThreadEnvMode: "worktree", | |
| 279 | }), | |
| 280 | ]) { | |
| 281 | expect(resolveNewThreadEnvMode({ ...newProject, projectSettings })).toBe("worktree"); | |
| 282 | } | |
| 283 | }); | |
| 284 | }); | |
| 285 | ||
| 226 | 286 | describe("projectSettingsOverrides patches", () => { |
| 227 | 287 | it("replaces a project's entry, removes it with null, and drops empty entries", () => { |
| 228 | 288 | const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
packages/shared/src/projectSettings.ts+37| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | type ThreadEnvMode, |
| 13 | 13 | type WorktreeCleanupRules, |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | import { normalizeProjectPathForComparison } from "./path.ts"; | |
| 15 | 16 | import { isModelSelectionProviderEnabled } from "./serverSettings.ts"; |
| 16 | 17 | |
| 17 | 18 | /** |
| ... | ... | @@ -188,6 +189,42 @@ function resolveProjectOverrides( |
| 188 | 189 | return { settings: effective as ServerSettings, sources, overrides }; |
| 189 | 190 | } |
| 190 | 191 | |
| 192 | /** | |
| 193 | * The workspace mode a new thread starts in. A project the environment made | |
| 194 | * from just a name (a folder directly inside `ServerConfig.newProjectsRoot`) | |
| 195 | * has only its first commit, so until it has a thread, a new thread works in | |
| 196 | * the project checkout even when the environment default is a new worktree. | |
| 197 | * Only that environment default is replaced: a project override still wins, | |
| 198 | * and so does a t3.json when the environment sets no default (the usual | |
| 199 | * precedence; the scaffold writes no t3.json). | |
| 200 | */ | |
| 201 | export function resolveNewThreadEnvMode(input: { | |
| 202 | readonly projectSettings: ResolvedProjectSettings<ResolvedServerSettings>; | |
| 203 | readonly workspaceRoot: string | null; | |
| 204 | readonly newProjectsRoot: string | null | undefined; | |
| 205 | readonly projectHasThreads: boolean; | |
| 206 | }): ThreadEnvMode { | |
| 207 | const configured = input.projectSettings.settings.defaultThreadEnvMode; | |
| 208 | if ( | |
| 209 | configured === "local" || | |
| 210 | input.projectHasThreads || | |
| 211 | input.projectSettings.sources.defaultThreadEnvMode !== "environment" || | |
| 212 | input.workspaceRoot === null || | |
| 213 | input.newProjectsRoot == null | |
| 214 | ) { | |
| 215 | return configured; | |
| 216 | } | |
| 217 | const root = normalizeProjectPathForComparison(input.newProjectsRoot); | |
| 218 | const folder = normalizeProjectPathForComparison(input.workspaceRoot); | |
| 219 | const separator = root.includes("\\") ? "\\" : "/"; | |
| 220 | const prefix = root.endsWith(separator) ? root : `${root}${separator}`; | |
| 221 | const isNewProjectFolder = | |
| 222 | folder.startsWith(prefix) && | |
| 223 | folder.length > prefix.length && | |
| 224 | !folder.slice(prefix.length).includes(separator); | |
| 225 | return isNewProjectFolder ? "local" : configured; | |
| 226 | } | |
| 227 | ||
| 191 | 228 | /** Replace the project's entry, dropping it entirely when nothing is overridden. */ |
| 192 | 229 | export function withProjectSettingsOverrides( |
| 193 | 230 | settings: Pick<ServerSettings, "projectSettingsOverrides">, |