| 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,7 +24,7 @@ import { |
| 24 | ThreadId, | 24 | ThreadId, |
| 25 | } from "@t3tools/contracts"; | 25 | } from "@t3tools/contracts"; |
| 26 | import { sanitizeNewRefName } from "@t3tools/shared/git"; | 26 | import { sanitizeNewRefName } from "@t3tools/shared/git"; |
| 27 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | 27 | import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 28 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; | 28 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; |
| 29 | import * as Arr from "effect/Array"; | 29 | import * as Arr from "effect/Array"; |
| 30 | import { pipe } from "effect/Function"; | 30 | import { pipe } from "effect/Function"; |
| ... | @@ -487,7 +487,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -487,7 +487,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 487 | const attachments = selectedProjectDraft.attachments; | 487 | const attachments = selectedProjectDraft.attachments; |
| 488 | // Default mode until the user picks one explicitly — same resolution web | 488 | // Default mode until the user picks one explicitly — same resolution web |
| 489 | // uses for new draft threads: per-project setting, then the repo's | 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 | const fileAccessSession = useEnvironmentQuery( | 492 | const fileAccessSession = useEnvironmentQuery( |
| 492 | selectedProject !== null && selectedProject.workspaceRoot !== "" | 493 | selectedProject !== null && selectedProject.workspaceRoot !== "" |
| 493 | ? environmentSession.sessionStateAtom(selectedProject.environmentId) | 494 | ? environmentSession.sessionStateAtom(selectedProject.environmentId) |
| ... | @@ -535,8 +536,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -535,8 +536,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 535 | // A thread without a project runs in a plain folder, so worktree mode | 536 | // A thread without a project runs in a plain folder, so worktree mode |
| 536 | // would leave it unsendable: it is always local and offers no choice. | 537 | // would leave it unsendable: it is always local and offers no choice. |
| 537 | const canChooseWorkspace = !isScratchDraft; | 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 | const defaultWorkspaceMode: WorkspaceMode = canChooseWorkspace | 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 | : "local"; | 553 | : "local"; |
| 541 | // While the file read is pending and nothing above it decided, the | 554 | // While the file read is pending and nothing above it decided, the |
| 542 | // resolved default is provisional. Nothing may write it into the draft | 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,6 +118,10 @@ vi.mock("@t3tools/shared/projectSettings", () => ({ |
| 118 | sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" }, | 118 | sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" }, |
| 119 | overrides: {}, | 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 | vi.mock("@tanstack/react-router", () => ({ | 126 | vi.mock("@tanstack/react-router", () => ({ |
| 123 | useParams: () => null, | 127 | useParams: () => null, |
| ... | @@ -166,6 +170,7 @@ vi.mock("../state/entities", () => ({ | ... | @@ -166,6 +170,7 @@ vi.mock("../state/entities", () => ({ |
| 166 | }, | 170 | }, |
| 167 | ], | 171 | ], |
| 168 | readThreadShell: () => null, | 172 | readThreadShell: () => null, |
| 173 | readThreadShells: () => [], | ||
| 169 | useProjects: () => [], | 174 | useProjects: () => [], |
| 170 | useThread: () => null, | 175 | useThread: () => null, |
| 171 | })); | 176 | })); |
apps/web/src/hooks/useHandleNewThread.ts+23-8| ... | @@ -22,8 +22,14 @@ import { | ... | @@ -22,8 +22,14 @@ import { |
| 22 | getProjectOrderKey, | 22 | getProjectOrderKey, |
| 23 | selectProjectGroupingSettings, | 23 | selectProjectGroupingSettings, |
| 24 | } from "../logicalProject"; | 24 | } from "../logicalProject"; |
| 25 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | 25 | import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 26 | import { readProjects, readThreadShell, useProjects, useThreadShell } from "../state/entities"; | 26 | import { |
| 27 | readProjects, | ||
| 28 | readThreadShell, | ||
| 29 | readThreadShells, | ||
| 30 | useProjects, | ||
| 31 | useThreadShell, | ||
| 32 | } from "../state/entities"; | ||
| 27 | import { | 33 | import { |
| 28 | hasExplicitComposerModelSelection, | 34 | hasExplicitComposerModelSelection, |
| 29 | resolveNewDraftStartFromOrigin, | 35 | resolveNewDraftStartFromOrigin, |
| ... | @@ -154,12 +160,21 @@ export function useNewThreadHandler() { | ... | @@ -154,12 +160,21 @@ export function useNewThreadHandler() { |
| 154 | const projectFile = consultProjectFile | 160 | const projectFile = consultProjectFile |
| 155 | ? await readT3ProjectFile(project.environmentId, project.workspaceRoot) | 161 | ? await readT3ProjectFile(project.environmentId, project.workspaceRoot) |
| 156 | : null; | 162 | : null; |
| 157 | return resolveProjectSettings( | 163 | return resolveNewThreadEnvMode({ |
| 158 | targetServerSettings, | 164 | projectSettings: resolveProjectSettings( |
| 159 | project?.id ?? null, | 165 | targetServerSettings, |
| 160 | project, | 166 | project?.id ?? null, |
| 161 | projectFile, | 167 | project, |
| 162 | ).settings.defaultThreadEnvMode; | 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 | const logicalProjectKey = project | 179 | const logicalProjectKey = project |
| 165 | ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) | 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,7 +119,8 @@ Merging a stack lands it through GitCafe, and updating a branch restacks the lay |
| 119 | To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or | 119 | To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or |
| 120 | **New project** under **Add Project** on any client, and type a name. T3 Code makes a Git | 120 | **New project** under **Add Project** on any client, and type a name. T3 Code makes a Git |
| 121 | repository in `~/.t3/projects` (the `projects` folder of your T3 data directory) with a README, | 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 | like `pinball-stats` for "Pinball Stats". Turn on **Create private repository on GitHub** to also | 124 | like `pinball-stats` for "Pinball Stats". Turn on **Create private repository on GitHub** to also |
| 124 | publish it. If Git has no name or email on that machine, the project is created without the | 125 | publish it. If Git has no name or email on that machine, the project is created without the |
| 125 | first commit. | 126 | first commit. |
packages/shared/src/projectSettings.test.ts+60| ... | @@ -10,6 +10,7 @@ import { createModelSelection } from "./model.ts"; | ... | @@ -10,6 +10,7 @@ import { createModelSelection } from "./model.ts"; |
| 10 | import { | 10 | import { |
| 11 | clearProjectSettingsOverrides, | 11 | clearProjectSettingsOverrides, |
| 12 | hasProjectSettingsOverrides, | 12 | hasProjectSettingsOverrides, |
| 13 | resolveNewThreadEnvMode, | ||
| 13 | resolveProjectFileBackedSetting, | 14 | resolveProjectFileBackedSetting, |
| 14 | resolveProjectSettings, | 15 | resolveProjectSettings, |
| 15 | resolveWorktreeCleanup, | 16 | resolveWorktreeCleanup, |
| ... | @@ -223,6 +224,65 @@ describe("resolveProjectSettings with a t3.json", () => { | ... | @@ -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 | describe("projectSettingsOverrides patches", () => { | 286 | describe("projectSettingsOverrides patches", () => { |
| 227 | it("replaces a project's entry, removes it with null, and drops empty entries", () => { | 287 | it("replaces a project's entry, removes it with null, and drops empty entries", () => { |
| 228 | const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { | 288 | const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
packages/shared/src/projectSettings.ts+37| ... | @@ -12,6 +12,7 @@ import { | ... | @@ -12,6 +12,7 @@ import { |
| 12 | type ThreadEnvMode, | 12 | type ThreadEnvMode, |
| 13 | type WorktreeCleanupRules, | 13 | type WorktreeCleanupRules, |
| 14 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 15 | import { normalizeProjectPathForComparison } from "./path.ts"; | ||
| 15 | import { isModelSelectionProviderEnabled } from "./serverSettings.ts"; | 16 | import { isModelSelectionProviderEnabled } from "./serverSettings.ts"; |
| 16 | 17 | ||
| 17 | /** | 18 | /** |
| ... | @@ -188,6 +189,42 @@ function resolveProjectOverrides( | ... | @@ -188,6 +189,42 @@ function resolveProjectOverrides( |
| 188 | return { settings: effective as ServerSettings, sources, overrides }; | 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 | /** Replace the project's entry, dropping it entirely when nothing is overridden. */ | 228 | /** Replace the project's entry, dropping it entirely when nothing is overridden. */ |
| 192 | export function withProjectSettingsOverrides( | 229 | export function withProjectSettingsOverrides( |
| 193 | settings: Pick<ServerSettings, "projectSettingsOverrides">, | 230 | settings: Pick<ServerSettings, "projectSettingsOverrides">, |