| author | |
| committer | |
| log | 6dbffa022d9e44091d0edc496181011e3acfb695 |
| tree | e25de219a2ee8e408270fc0719a11bf2fc1d1356 |
| parent | 5bb8c0366447b1bb94c0ac41f2d4cd06730ffb94 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>28 files changed, 610 insertions(+), 55 deletions(-)
apps/mobile/src/features/threads/new-task-flow-provider.tsx+51-6| ... | @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" | ... | @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" |
| 3 | import type { | 3 | import type { |
| 4 | EnvironmentId, | 4 | EnvironmentId, |
| 5 | ModelSelection, | 5 | ModelSelection, |
| 6 | ProjectReadFileResult, | ||
| 6 | ProviderInteractionMode, | 7 | ProviderInteractionMode, |
| 7 | ProviderOptionSelection, | 8 | ProviderOptionSelection, |
| 8 | RuntimeMode, | 9 | RuntimeMode, |
| ... | @@ -13,8 +14,14 @@ import { | ... | @@ -13,8 +14,14 @@ import { |
| 13 | DEFAULT_PROVIDER_INTERACTION_MODE, | 14 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 14 | DEFAULT_RUNTIME_MODE, | 15 | DEFAULT_RUNTIME_MODE, |
| 15 | MessageId, | 16 | MessageId, |
| 17 | T3_PROJECT_FILE_NAME, | ||
| 16 | ThreadId, | 18 | ThreadId, |
| 17 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 20 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; | ||
| 21 | import { | ||
| 22 | isDefaultThreadEnvModeSettled, | ||
| 23 | resolveDefaultThreadEnvMode, | ||
| 24 | } from "@t3tools/shared/threadEnvMode"; | ||
| 18 | import * as Arr from "effect/Array"; | 25 | import * as Arr from "effect/Array"; |
| 19 | import { pipe } from "effect/Function"; | 26 | import { pipe } from "effect/Function"; |
| 20 | 27 | ||
| ... | @@ -30,6 +37,8 @@ import { | ... | @@ -30,6 +37,8 @@ import { |
| 30 | } from "../../lib/modelOptions"; | 37 | } from "../../lib/modelOptions"; |
| 31 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 38 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 32 | import { appAtomRegistry } from "../../state/atom-registry"; | 39 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 40 | import { projectEnvironment } from "../../state/projects"; | ||
| 41 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 33 | import { | 42 | import { |
| 34 | appendComposerDraftAttachments, | 43 | appendComposerDraftAttachments, |
| 35 | clearComposerDraft, | 44 | clearComposerDraft, |
| ... | @@ -345,10 +354,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -345,10 +354,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 345 | const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); | 354 | const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); |
| 346 | const prompt = selectedProjectDraft.text; | 355 | const prompt = selectedProjectDraft.text; |
| 347 | const attachments = selectedProjectDraft.attachments; | 356 | const attachments = selectedProjectDraft.attachments; |
| 348 | // The server's configured default decides the mode until the user picks one | 357 | // Default mode until the user picks one explicitly — same resolution web |
| 349 | // explicitly — same resolution web uses for new draft threads. | 358 | // uses for new draft threads: per-project setting, then the repo's |
| 350 | const defaultWorkspaceMode: WorkspaceMode = | 359 | // checked-in t3.json, then the server's configured default. |
| 351 | selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local"; | 360 | const t3ProjectFileQuery = useEnvironmentQuery( |
| 361 | selectedProject !== null && selectedProject.workspaceRoot !== "" | ||
| 362 | ? projectEnvironment.readFile({ | ||
| 363 | environmentId: selectedProject.environmentId, | ||
| 364 | input: { cwd: selectedProject.workspaceRoot, relativePath: T3_PROJECT_FILE_NAME }, | ||
| 365 | }) | ||
| 366 | : null, | ||
| 367 | ); | ||
| 368 | const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null; | ||
| 369 | const t3ProjectFileDefaultMode = useMemo(() => { | ||
| 370 | if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; | ||
| 371 | return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; | ||
| 372 | }, [t3ProjectFileData]); | ||
| 373 | const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ | ||
| 374 | projectSetting: selectedProject?.defaultThreadEnvMode, | ||
| 375 | projectFile: t3ProjectFileDefaultMode, | ||
| 376 | globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local", | ||
| 377 | }); | ||
| 378 | // While unsettled the resolved default is provisional. Nothing may write | ||
| 379 | // it into the draft during that window (the auto-branch effect does), or | ||
| 380 | // the frozen interim value beats the t3.json default once it loads. | ||
| 381 | const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ | ||
| 382 | explicitMode: selectedProjectDraft.workspaceSelection?.mode, | ||
| 383 | projectSetting: selectedProject?.defaultThreadEnvMode, | ||
| 384 | projectFilePending: t3ProjectFileQuery.isPending, | ||
| 385 | }); | ||
| 352 | const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; | 386 | const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; |
| 353 | const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; | 387 | const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; |
| 354 | const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; | 388 | const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; |
| ... | @@ -618,7 +652,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -618,7 +652,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 618 | }, [refreshBranches, selectedProject]); | 652 | }, [refreshBranches, selectedProject]); |
| 619 | 653 | ||
| 620 | useEffect(() => { | 654 | useEffect(() => { |
| 621 | if (workspaceMode !== "worktree" || selectedBranchName !== null) { | 655 | if ( |
| 656 | !defaultWorkspaceModeSettled || | ||
| 657 | workspaceMode !== "worktree" || | ||
| 658 | selectedBranchName !== null | ||
| 659 | ) { | ||
| 622 | return; | 660 | return; |
| 623 | } | 661 | } |
| 624 | // The default may only exist as origin/<default> (isRemote), which | 662 | // The default may only exist as origin/<default> (isRemote), which |
| ... | @@ -630,7 +668,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -630,7 +668,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 630 | if (preferredBranch) { | 668 | if (preferredBranch) { |
| 631 | selectBranch(preferredBranch); | 669 | selectBranch(preferredBranch); |
| 632 | } | 670 | } |
| 633 | }, [allBranchRefs, availableBranches, selectBranch, selectedBranchName, workspaceMode]); | 671 | }, [ |
| 672 | allBranchRefs, | ||
| 673 | availableBranches, | ||
| 674 | defaultWorkspaceModeSettled, | ||
| 675 | selectBranch, | ||
| 676 | selectedBranchName, | ||
| 677 | workspaceMode, | ||
| 678 | ]); | ||
| 634 | 679 | ||
| 635 | const setRuntimeMode = useCallback( | 680 | const setRuntimeMode = useCallback( |
| 636 | (value: RuntimeMode) => { | 681 | (value: RuntimeMode) => { |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4| ... | @@ -495,6 +495,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -495,6 +495,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 495 | title: event.payload.title, | 495 | title: event.payload.title, |
| 496 | workspaceRoot: event.payload.workspaceRoot, | 496 | workspaceRoot: event.payload.workspaceRoot, |
| 497 | defaultModelSelection: event.payload.defaultModelSelection, | 497 | defaultModelSelection: event.payload.defaultModelSelection, |
| 498 | defaultThreadEnvMode: null, | ||
| 498 | scripts: event.payload.scripts, | 499 | scripts: event.payload.scripts, |
| 499 | createdAt: event.payload.createdAt, | 500 | createdAt: event.payload.createdAt, |
| 500 | updatedAt: event.payload.updatedAt, | 501 | updatedAt: event.payload.updatedAt, |
| ... | @@ -518,6 +519,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -518,6 +519,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 518 | ...(event.payload.defaultModelSelection !== undefined | 519 | ...(event.payload.defaultModelSelection !== undefined |
| 519 | ? { defaultModelSelection: event.payload.defaultModelSelection } | 520 | ? { defaultModelSelection: event.payload.defaultModelSelection } |
| 520 | : {}), | 521 | : {}), |
| 522 | ...(event.payload.defaultThreadEnvMode !== undefined | ||
| 523 | ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode } | ||
| 524 | : {}), | ||
| 521 | ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), | 525 | ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), |
| 522 | updatedAt: event.payload.updatedAt, | 526 | updatedAt: event.payload.updatedAt, |
| 523 | }); | 527 | }); |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2| ... | @@ -284,6 +284,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -284,6 +284,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 284 | runOnWorktreeCreate: false, | 284 | runOnWorktreeCreate: false, |
| 285 | }, | 285 | }, |
| 286 | ], | 286 | ], |
| 287 | defaultThreadEnvMode: null, | ||
| 287 | createdAt: "2026-02-24T00:00:00.000Z", | 288 | createdAt: "2026-02-24T00:00:00.000Z", |
| 288 | updatedAt: "2026-02-24T00:00:01.000Z", | 289 | updatedAt: "2026-02-24T00:00:01.000Z", |
| 289 | deletedAt: null, | 290 | deletedAt: null, |
| ... | @@ -402,6 +403,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -402,6 +403,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 402 | runOnWorktreeCreate: false, | 403 | runOnWorktreeCreate: false, |
| 403 | }, | 404 | }, |
| 404 | ], | 405 | ], |
| 406 | defaultThreadEnvMode: null, | ||
| 405 | createdAt: "2026-02-24T00:00:00.000Z", | 407 | createdAt: "2026-02-24T00:00:00.000Z", |
| 406 | updatedAt: "2026-02-24T00:00:01.000Z", | 408 | updatedAt: "2026-02-24T00:00:01.000Z", |
| 407 | }, | 409 | }, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+7| ... | @@ -316,6 +316,7 @@ function mapProjectShellRow( | ... | @@ -316,6 +316,7 @@ function mapProjectShellRow( |
| 316 | workspaceRoot: row.workspaceRoot, | 316 | workspaceRoot: row.workspaceRoot, |
| 317 | repositoryIdentity, | 317 | repositoryIdentity, |
| 318 | defaultModelSelection: row.defaultModelSelection, | 318 | defaultModelSelection: row.defaultModelSelection, |
| 319 | defaultThreadEnvMode: row.defaultThreadEnvMode, | ||
| 319 | scripts: row.scripts, | 320 | scripts: row.scripts, |
| 320 | createdAt: row.createdAt, | 321 | createdAt: row.createdAt, |
| 321 | updatedAt: row.updatedAt, | 322 | updatedAt: row.updatedAt, |
| ... | @@ -391,6 +392,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -391,6 +392,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 391 | title, | 392 | title, |
| 392 | workspace_root AS "workspaceRoot", | 393 | workspace_root AS "workspaceRoot", |
| 393 | default_model_selection_json AS "defaultModelSelection", | 394 | default_model_selection_json AS "defaultModelSelection", |
| 395 | default_thread_env_mode AS "defaultThreadEnvMode", | ||
| 394 | scripts_json AS "scripts", | 396 | scripts_json AS "scripts", |
| 395 | created_at AS "createdAt", | 397 | created_at AS "createdAt", |
| 396 | updated_at AS "updatedAt", | 398 | updated_at AS "updatedAt", |
| ... | @@ -844,6 +846,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -844,6 +846,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 844 | title, | 846 | title, |
| 845 | workspace_root AS "workspaceRoot", | 847 | workspace_root AS "workspaceRoot", |
| 846 | default_model_selection_json AS "defaultModelSelection", | 848 | default_model_selection_json AS "defaultModelSelection", |
| 849 | default_thread_env_mode AS "defaultThreadEnvMode", | ||
| 847 | scripts_json AS "scripts", | 850 | scripts_json AS "scripts", |
| 848 | created_at AS "createdAt", | 851 | created_at AS "createdAt", |
| 849 | updated_at AS "updatedAt", | 852 | updated_at AS "updatedAt", |
| ... | @@ -866,6 +869,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -866,6 +869,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 866 | title, | 869 | title, |
| 867 | workspace_root AS "workspaceRoot", | 870 | workspace_root AS "workspaceRoot", |
| 868 | default_model_selection_json AS "defaultModelSelection", | 871 | default_model_selection_json AS "defaultModelSelection", |
| 872 | default_thread_env_mode AS "defaultThreadEnvMode", | ||
| 869 | scripts_json AS "scripts", | 873 | scripts_json AS "scripts", |
| 870 | created_at AS "createdAt", | 874 | created_at AS "createdAt", |
| 871 | updated_at AS "updatedAt", | 875 | updated_at AS "updatedAt", |
| ... | @@ -1542,6 +1546,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1542,6 +1546,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1542 | workspaceRoot: row.workspaceRoot, | 1546 | workspaceRoot: row.workspaceRoot, |
| 1543 | repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, | 1547 | repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, |
| 1544 | defaultModelSelection: row.defaultModelSelection, | 1548 | defaultModelSelection: row.defaultModelSelection, |
| 1549 | defaultThreadEnvMode: row.defaultThreadEnvMode, | ||
| 1545 | scripts: row.scripts, | 1550 | scripts: row.scripts, |
| 1546 | createdAt: row.createdAt, | 1551 | createdAt: row.createdAt, |
| 1547 | updatedAt: row.updatedAt, | 1552 | updatedAt: row.updatedAt, |
| ... | @@ -1671,6 +1676,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1671,6 +1676,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1671 | title: row.title, | 1676 | title: row.title, |
| 1672 | workspaceRoot: row.workspaceRoot, | 1677 | workspaceRoot: row.workspaceRoot, |
| 1673 | defaultModelSelection: row.defaultModelSelection, | 1678 | defaultModelSelection: row.defaultModelSelection, |
| 1679 | defaultThreadEnvMode: row.defaultThreadEnvMode, | ||
| 1674 | scripts: row.scripts, | 1680 | scripts: row.scripts, |
| 1675 | createdAt: row.createdAt, | 1681 | createdAt: row.createdAt, |
| 1676 | updatedAt: row.updatedAt, | 1682 | updatedAt: row.updatedAt, |
| ... | @@ -2162,6 +2168,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -2162,6 +2168,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2162 | workspaceRoot: option.value.workspaceRoot, | 2168 | workspaceRoot: option.value.workspaceRoot, |
| 2163 | repositoryIdentity, | 2169 | repositoryIdentity, |
| 2164 | defaultModelSelection: option.value.defaultModelSelection, | 2170 | defaultModelSelection: option.value.defaultModelSelection, |
| 2171 | defaultThreadEnvMode: option.value.defaultThreadEnvMode, | ||
| 2165 | scripts: option.value.scripts, | 2172 | scripts: option.value.scripts, |
| 2166 | createdAt: option.value.createdAt, | 2173 | createdAt: option.value.createdAt, |
| 2167 | updatedAt: option.value.updatedAt, | 2174 | updatedAt: option.value.updatedAt, |
apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts created+103| ... | @@ -0,0 +1,103 @@ | ||
| 1 | import { CommandId, EventId, ProjectId, type OrchestrationEvent } from "@t3tools/contracts"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | |||
| 6 | import { decideOrchestrationCommand } from "./decider.ts"; | ||
| 7 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; | ||
| 8 | |||
| 9 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 10 | const projectId = ProjectId.make("project-env-mode"); | ||
| 11 | |||
| 12 | const seedProjectCreated = (sequence: number): OrchestrationEvent => ({ | ||
| 13 | sequence, | ||
| 14 | eventId: EventId.make(`evt-project-env-mode-${sequence}`), | ||
| 15 | aggregateKind: "project", | ||
| 16 | aggregateId: projectId, | ||
| 17 | type: "project.created", | ||
| 18 | occurredAt: now, | ||
| 19 | commandId: CommandId.make(`cmd-project-env-mode-${sequence}`), | ||
| 20 | causationEventId: null, | ||
| 21 | correlationId: CommandId.make(`cmd-project-env-mode-${sequence}`), | ||
| 22 | metadata: {}, | ||
| 23 | payload: { | ||
| 24 | projectId, | ||
| 25 | title: "Env mode", | ||
| 26 | workspaceRoot: "/tmp/env-mode", | ||
| 27 | defaultModelSelection: null, | ||
| 28 | scripts: [], | ||
| 29 | createdAt: now, | ||
| 30 | updatedAt: now, | ||
| 31 | }, | ||
| 32 | }); | ||
| 33 | |||
| 34 | it.layer(NodeServices.layer)("decider project defaultThreadEnvMode", (it) => { | ||
| 35 | it.effect("propagates defaultThreadEnvMode through meta.update into the read model", () => | ||
| 36 | Effect.gen(function* () { | ||
| 37 | const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1)); | ||
| 38 | expect(readModel.projects[0]?.defaultThreadEnvMode).toBeNull(); | ||
| 39 | |||
| 40 | const result = yield* decideOrchestrationCommand({ | ||
| 41 | command: { | ||
| 42 | type: "project.meta.update", | ||
| 43 | commandId: CommandId.make("cmd-project-env-mode-set"), | ||
| 44 | projectId, | ||
| 45 | defaultThreadEnvMode: "worktree", | ||
| 46 | }, | ||
| 47 | readModel, | ||
| 48 | }); | ||
| 49 | |||
| 50 | const event = Array.isArray(result) ? result[0] : result; | ||
| 51 | expect(event.type).toBe("project.meta-updated"); | ||
| 52 | expect((event.payload as { defaultThreadEnvMode?: unknown }).defaultThreadEnvMode).toBe( | ||
| 53 | "worktree", | ||
| 54 | ); | ||
| 55 | |||
| 56 | const updated = yield* projectEvent(readModel, { ...event, sequence: 2 }); | ||
| 57 | expect(updated.projects[0]?.defaultThreadEnvMode).toBe("worktree"); | ||
| 58 | }), | ||
| 59 | ); | ||
| 60 | |||
| 61 | it.effect("omits the field when unset and clears it on explicit null", () => | ||
| 62 | Effect.gen(function* () { | ||
| 63 | const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1)); | ||
| 64 | |||
| 65 | const unrelated = yield* decideOrchestrationCommand({ | ||
| 66 | command: { | ||
| 67 | type: "project.meta.update", | ||
| 68 | commandId: CommandId.make("cmd-project-env-mode-title"), | ||
| 69 | projectId, | ||
| 70 | title: "Renamed", | ||
| 71 | }, | ||
| 72 | readModel, | ||
| 73 | }); | ||
| 74 | const unrelatedEvent = Array.isArray(unrelated) ? unrelated[0] : unrelated; | ||
| 75 | expect("defaultThreadEnvMode" in (unrelatedEvent.payload as object)).toBe(false); | ||
| 76 | |||
| 77 | const set = yield* decideOrchestrationCommand({ | ||
| 78 | command: { | ||
| 79 | type: "project.meta.update", | ||
| 80 | commandId: CommandId.make("cmd-project-env-mode-set"), | ||
| 81 | projectId, | ||
| 82 | defaultThreadEnvMode: "worktree", | ||
| 83 | }, | ||
| 84 | readModel, | ||
| 85 | }); | ||
| 86 | const setEvent = Array.isArray(set) ? set[0] : set; | ||
| 87 | const afterSet = yield* projectEvent(readModel, { ...setEvent, sequence: 2 }); | ||
| 88 | |||
| 89 | const clear = yield* decideOrchestrationCommand({ | ||
| 90 | command: { | ||
| 91 | type: "project.meta.update", | ||
| 92 | commandId: CommandId.make("cmd-project-env-mode-clear"), | ||
| 93 | projectId, | ||
| 94 | defaultThreadEnvMode: null, | ||
| 95 | }, | ||
| 96 | readModel: afterSet, | ||
| 97 | }); | ||
| 98 | const clearEvent = Array.isArray(clear) ? clear[0] : clear; | ||
| 99 | const afterClear = yield* projectEvent(afterSet, { ...clearEvent, sequence: 3 }); | ||
| 100 | expect(afterClear.projects[0]?.defaultThreadEnvMode).toBeNull(); | ||
| 101 | }), | ||
| 102 | ); | ||
| 103 | }); | ||
apps/server/src/orchestration/decider.ts+3| ... | @@ -287,6 +287,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -287,6 +287,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 287 | ...(command.defaultModelSelection !== undefined | 287 | ...(command.defaultModelSelection !== undefined |
| 288 | ? { defaultModelSelection: command.defaultModelSelection } | 288 | ? { defaultModelSelection: command.defaultModelSelection } |
| 289 | : {}), | 289 | : {}), |
| 290 | ...(command.defaultThreadEnvMode !== undefined | ||
| 291 | ? { defaultThreadEnvMode: command.defaultThreadEnvMode } | ||
| 292 | : {}), | ||
| 290 | ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), | 293 | ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), |
| 291 | updatedAt: occurredAt, | 294 | updatedAt: occurredAt, |
| 292 | }, | 295 | }, |
apps/server/src/orchestration/projector.ts+4| ... | @@ -214,6 +214,7 @@ export function projectEvent( | ... | @@ -214,6 +214,7 @@ export function projectEvent( |
| 214 | title: payload.title, | 214 | title: payload.title, |
| 215 | workspaceRoot: payload.workspaceRoot, | 215 | workspaceRoot: payload.workspaceRoot, |
| 216 | defaultModelSelection: payload.defaultModelSelection, | 216 | defaultModelSelection: payload.defaultModelSelection, |
| 217 | defaultThreadEnvMode: null, | ||
| 217 | scripts: payload.scripts, | 218 | scripts: payload.scripts, |
| 218 | createdAt: payload.createdAt, | 219 | createdAt: payload.createdAt, |
| 219 | updatedAt: payload.updatedAt, | 220 | updatedAt: payload.updatedAt, |
| ... | @@ -246,6 +247,9 @@ export function projectEvent( | ... | @@ -246,6 +247,9 @@ export function projectEvent( |
| 246 | ...(payload.defaultModelSelection !== undefined | 247 | ...(payload.defaultModelSelection !== undefined |
| 247 | ? { defaultModelSelection: payload.defaultModelSelection } | 248 | ? { defaultModelSelection: payload.defaultModelSelection } |
| 248 | : {}), | 249 | : {}), |
| 250 | ...(payload.defaultThreadEnvMode !== undefined | ||
| 251 | ? { defaultThreadEnvMode: payload.defaultThreadEnvMode } | ||
| 252 | : {}), | ||
| 249 | ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), | 253 | ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), |
| 250 | updatedAt: payload.updatedAt, | 254 | updatedAt: payload.updatedAt, |
| 251 | } | 255 | } |
apps/server/src/persistence/Layers/ProjectionProjects.ts+5| ... | @@ -35,6 +35,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { | ... | @@ -35,6 +35,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 35 | title, | 35 | title, |
| 36 | workspace_root, | 36 | workspace_root, |
| 37 | default_model_selection_json, | 37 | default_model_selection_json, |
| 38 | default_thread_env_mode, | ||
| 38 | scripts_json, | 39 | scripts_json, |
| 39 | created_at, | 40 | created_at, |
| 40 | updated_at, | 41 | updated_at, |
| ... | @@ -45,6 +46,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { | ... | @@ -45,6 +46,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 45 | ${row.title}, | 46 | ${row.title}, |
| 46 | ${row.workspaceRoot}, | 47 | ${row.workspaceRoot}, |
| 47 | ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, | 48 | ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, |
| 49 | ${row.defaultThreadEnvMode}, | ||
| 48 | ${JSON.stringify(row.scripts)}, | 50 | ${JSON.stringify(row.scripts)}, |
| 49 | ${row.createdAt}, | 51 | ${row.createdAt}, |
| 50 | ${row.updatedAt}, | 52 | ${row.updatedAt}, |
| ... | @@ -55,6 +57,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { | ... | @@ -55,6 +57,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 55 | title = excluded.title, | 57 | title = excluded.title, |
| 56 | workspace_root = excluded.workspace_root, | 58 | workspace_root = excluded.workspace_root, |
| 57 | default_model_selection_json = excluded.default_model_selection_json, | 59 | default_model_selection_json = excluded.default_model_selection_json, |
| 60 | default_thread_env_mode = excluded.default_thread_env_mode, | ||
| 58 | scripts_json = excluded.scripts_json, | 61 | scripts_json = excluded.scripts_json, |
| 59 | created_at = excluded.created_at, | 62 | created_at = excluded.created_at, |
| 60 | updated_at = excluded.updated_at, | 63 | updated_at = excluded.updated_at, |
| ... | @@ -72,6 +75,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { | ... | @@ -72,6 +75,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 72 | title, | 75 | title, |
| 73 | workspace_root AS "workspaceRoot", | 76 | workspace_root AS "workspaceRoot", |
| 74 | default_model_selection_json AS "defaultModelSelection", | 77 | default_model_selection_json AS "defaultModelSelection", |
| 78 | default_thread_env_mode AS "defaultThreadEnvMode", | ||
| 75 | scripts_json AS "scripts", | 79 | scripts_json AS "scripts", |
| 76 | created_at AS "createdAt", | 80 | created_at AS "createdAt", |
| 77 | updated_at AS "updatedAt", | 81 | updated_at AS "updatedAt", |
| ... | @@ -91,6 +95,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { | ... | @@ -91,6 +95,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 91 | title, | 95 | title, |
| 92 | workspace_root AS "workspaceRoot", | 96 | workspace_root AS "workspaceRoot", |
| 93 | default_model_selection_json AS "defaultModelSelection", | 97 | default_model_selection_json AS "defaultModelSelection", |
| 98 | default_thread_env_mode AS "defaultThreadEnvMode", | ||
| 94 | scripts_json AS "scripts", | 99 | scripts_json AS "scripts", |
| 95 | created_at AS "createdAt", | 100 | created_at AS "createdAt", |
| 96 | updated_at AS "updatedAt", | 101 | updated_at AS "updatedAt", |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+1| ... | @@ -33,6 +33,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -33,6 +33,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 33 | instanceId: ProviderInstanceId.make("codex"), | 33 | instanceId: ProviderInstanceId.make("codex"), |
| 34 | model: "gpt-5.4", | 34 | model: "gpt-5.4", |
| 35 | }, | 35 | }, |
| 36 | defaultThreadEnvMode: null, | ||
| 36 | scripts: [], | 37 | scripts: [], |
| 37 | createdAt: "2026-03-24T00:00:00.000Z", | 38 | createdAt: "2026-03-24T00:00:00.000Z", |
| 38 | updatedAt: "2026-03-24T00:00:00.000Z", | 39 | updatedAt: "2026-03-24T00:00:00.000Z", |
apps/server/src/persistence/Migrations.ts+2| ... | @@ -51,6 +51,7 @@ import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts | ... | @@ -51,6 +51,7 @@ import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts |
| 51 | import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; | 51 | import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; |
| 52 | import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; | 52 | import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; |
| 53 | import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; | 53 | import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; |
| 54 | import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; | ||
| 54 | 55 | ||
| 55 | /** | 56 | /** |
| 56 | * Migration loader with all migrations defined inline. | 57 | * Migration loader with all migrations defined inline. |
| ... | @@ -101,6 +102,7 @@ export const migrationEntries = [ | ... | @@ -101,6 +102,7 @@ export const migrationEntries = [ |
| 101 | [36, "ProjectionThreadsPinned", Migration0036], | 102 | [36, "ProjectionThreadsPinned", Migration0036], |
| 102 | [37, "ProjectionTurnsKeysetIndex", Migration0037], | 103 | [37, "ProjectionTurnsKeysetIndex", Migration0037], |
| 103 | [38, "ProjectionThreadsPinOrderKey", Migration0038], | 104 | [38, "ProjectionThreadsPinOrderKey", Migration0038], |
| 105 | [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], | ||
| 104 | ] as const; | 106 | ] as const; |
| 105 | 107 | ||
| 106 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); | 108 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
apps/server/src/persistence/Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 3 | |||
| 4 | export default Effect.gen(function* () { | ||
| 5 | const sql = yield* SqlClient.SqlClient; | ||
| 6 | const columns = yield* sql<{ readonly name: string }>` | ||
| 7 | PRAGMA table_info(projection_projects) | ||
| 8 | `; | ||
| 9 | |||
| 10 | if (!columns.some((column) => column.name === "default_thread_env_mode")) { | ||
| 11 | yield* sql` | ||
| 12 | ALTER TABLE projection_projects | ||
| 13 | ADD COLUMN default_thread_env_mode TEXT | ||
| 14 | `; | ||
| 15 | } | ||
| 16 | }); | ||
apps/server/src/persistence/Services/ProjectionProjects.ts+8-1| ... | @@ -6,7 +6,13 @@ | ... | @@ -6,7 +6,13 @@ |
| 6 | * | 6 | * |
| 7 | * @module ProjectionProjectRepository | 7 | * @module ProjectionProjectRepository |
| 8 | */ | 8 | */ |
| 9 | import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; | 9 | import { |
| 10 | IsoDateTime, | ||
| 11 | ModelSelection, | ||
| 12 | ProjectId, | ||
| 13 | ProjectScript, | ||
| 14 | ThreadEnvMode, | ||
| 15 | } from "@t3tools/contracts"; | ||
| 10 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| 11 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| 12 | import * as Context from "effect/Context"; | 18 | import * as Context from "effect/Context"; |
| ... | @@ -19,6 +25,7 @@ export const ProjectionProject = Schema.Struct({ | ... | @@ -19,6 +25,7 @@ export const ProjectionProject = Schema.Struct({ |
| 19 | title: Schema.String, | 25 | title: Schema.String, |
| 20 | workspaceRoot: Schema.String, | 26 | workspaceRoot: Schema.String, |
| 21 | defaultModelSelection: Schema.NullOr(ModelSelection), | 27 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 28 | defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), | ||
| 22 | scripts: Schema.Array(ProjectScript), | 29 | scripts: Schema.Array(ProjectScript), |
| 23 | createdAt: IsoDateTime, | 30 | createdAt: IsoDateTime, |
| 24 | updatedAt: IsoDateTime, | 31 | updatedAt: IsoDateTime, |
apps/web/src/components/settings/ProjectSettingsPanel.tsx+62| ... | @@ -17,7 +17,9 @@ import type { | ... | @@ -17,7 +17,9 @@ import type { |
| 17 | ProviderDriverKind, | 17 | ProviderDriverKind, |
| 18 | SidebarProjectGroupingMode, | 18 | SidebarProjectGroupingMode, |
| 19 | T3ProjectFileScript, | 19 | T3ProjectFileScript, |
| 20 | ThreadEnvMode, | ||
| 20 | } from "@t3tools/contracts"; | 21 | } from "@t3tools/contracts"; |
| 22 | import { resolveEnvModeLabel } from "../BranchToolbar.logic"; | ||
| 21 | import { createModelSelection } from "@t3tools/shared/model"; | 23 | import { createModelSelection } from "@t3tools/shared/model"; |
| 22 | import { useLocation, useNavigate } from "@tanstack/react-router"; | 24 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 23 | import * as Cause from "effect/Cause"; | 25 | import * as Cause from "effect/Cause"; |
| ... | @@ -325,6 +327,7 @@ function ProjectDetail({ | ... | @@ -325,6 +327,7 @@ function ProjectDetail({ |
| 325 | async ( | 327 | async ( |
| 326 | input: Partial<{ | 328 | input: Partial<{ |
| 327 | defaultModelSelection: ModelSelection | null; | 329 | defaultModelSelection: ModelSelection | null; |
| 330 | defaultThreadEnvMode: ThreadEnvMode | null; | ||
| 328 | scripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>; | 331 | scripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>; |
| 329 | }>, | 332 | }>, |
| 330 | failureTitle: string, | 333 | failureTitle: string, |
| ... | @@ -377,6 +380,17 @@ function ProjectDetail({ | ... | @@ -377,6 +380,17 @@ function ProjectDetail({ |
| 377 | [updateAllMembers], | 380 | [updateAllMembers], |
| 378 | ); | 381 | ); |
| 379 | 382 | ||
| 383 | // ----- new-thread workspace mode ----- | ||
| 384 | const storedEnvMode = representative.defaultThreadEnvMode ?? null; | ||
| 385 | const setDefaultThreadEnvMode = useCallback( | ||
| 386 | (mode: ThreadEnvMode | null) => | ||
| 387 | void updateAllMembers( | ||
| 388 | { defaultThreadEnvMode: mode }, | ||
| 389 | "Failed to update new-thread workspace", | ||
| 390 | ), | ||
| 391 | [updateAllMembers], | ||
| 392 | ); | ||
| 393 | |||
| 380 | // ----- scripts ----- | 394 | // ----- scripts ----- |
| 381 | const scripts = representative.scripts; | 395 | const scripts = representative.scripts; |
| 382 | const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null); | 396 | const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null); |
| ... | @@ -385,6 +399,10 @@ function ProjectDetail({ | ... | @@ -385,6 +399,10 @@ function ProjectDetail({ |
| 385 | const [isSavingScripts, setIsSavingScripts] = useState(false); | 399 | const [isSavingScripts, setIsSavingScripts] = useState(false); |
| 386 | const savingScriptsRef = useRef(false); | 400 | const savingScriptsRef = useRef(false); |
| 387 | const t3File = useT3ProjectFileState(representative.environmentId, representative.workspaceRoot); | 401 | const t3File = useT3ProjectFileState(representative.environmentId, representative.workspaceRoot); |
| 402 | // What the "Default" option resolves to while no override is set: the | ||
| 403 | // repo's t3.json value when present, otherwise the global setting. | ||
| 404 | const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode; | ||
| 405 | const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global"; | ||
| 388 | const importableScripts = useMemo( | 406 | const importableScripts = useMemo( |
| 389 | () => | 407 | () => |
| 390 | t3File.scripts.filter( | 408 | t3File.scripts.filter( |
| ... | @@ -761,6 +779,50 @@ function ProjectDetail({ | ... | @@ -761,6 +779,50 @@ function ProjectDetail({ |
| 761 | /> | 779 | /> |
| 762 | </SettingsSection> | 780 | </SettingsSection> |
| 763 | 781 | ||
| 782 | <SettingsSection title="New threads"> | ||
| 783 | <SettingsRow | ||
| 784 | id="project-new-thread-workspace" | ||
| 785 | title="Workspace" | ||
| 786 | description="Where new threads in this project start. Overrides t3.json and the global default; applies to every checkout in this group." | ||
| 787 | resetAction={ | ||
| 788 | storedEnvMode !== null ? ( | ||
| 789 | <SettingResetButton | ||
| 790 | label="project workspace default" | ||
| 791 | onClick={() => setDefaultThreadEnvMode(null)} | ||
| 792 | /> | ||
| 793 | ) : null | ||
| 794 | } | ||
| 795 | control={ | ||
| 796 | <Select | ||
| 797 | value={storedEnvMode ?? "inherit"} | ||
| 798 | onValueChange={(value) => { | ||
| 799 | if (value === "worktree" || value === "local") { | ||
| 800 | setDefaultThreadEnvMode(value); | ||
| 801 | } else if (value === "inherit") { | ||
| 802 | setDefaultThreadEnvMode(null); | ||
| 803 | } | ||
| 804 | }} | ||
| 805 | > | ||
| 806 | <SelectTrigger aria-label="New-thread workspace"> | ||
| 807 | <SelectValue> | ||
| 808 | {storedEnvMode === null | ||
| 809 | ? `Default (${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})` | ||
| 810 | : resolveEnvModeLabel(storedEnvMode)} | ||
| 811 | </SelectValue> | ||
| 812 | </SelectTrigger> | ||
| 813 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 814 | <SelectItem value="inherit"> | ||
| 815 | Default ({inheritedEnvModeSource}:{" "} | ||
| 816 | {resolveEnvModeLabel(inheritedEnvMode).toLowerCase()}) | ||
| 817 | </SelectItem> | ||
| 818 | <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem> | ||
| 819 | <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem> | ||
| 820 | </SelectPopup> | ||
| 821 | </Select> | ||
| 822 | } | ||
| 823 | /> | ||
| 824 | </SettingsSection> | ||
| 825 | |||
| 764 | <SettingsSection | 826 | <SettingsSection |
| 765 | id="project-scripts" | 827 | id="project-scripts" |
| 766 | title="Scripts" | 828 | title="Scripts" |
apps/web/src/components/settings/settingsSearch.test.ts+2-1| ... | @@ -45,7 +45,8 @@ describe("searchSettings", () => { | ... | @@ -45,7 +45,8 @@ describe("searchSettings", () => { |
| 45 | 45 | ||
| 46 | it("matches normalized title substrings", () => { | 46 | it("matches normalized title substrings", () => { |
| 47 | expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); | 47 | expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); |
| 48 | expect(searchSettings("work")).toEqual([]); | 48 | expect(searchSettings("work").map((item) => item.id)).toEqual(["project-new-thread-workspace"]); |
| 49 | expect(searchSettings("xyzzy")).toEqual([]); | ||
| 49 | }); | 50 | }); |
| 50 | 51 | ||
| 51 | it("keeps catalog order for multiple title matches", () => { | 52 | it("keeps catalog order for multiple title matches", () => { |
apps/web/src/components/settings/settingsSearch.ts+5| ... | @@ -186,6 +186,11 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -186,6 +186,11 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 186 | title: "Project default model", | 186 | title: "Project default model", |
| 187 | to: "/settings/projects", | 187 | to: "/settings/projects", |
| 188 | }, | 188 | }, |
| 189 | { | ||
| 190 | id: "project-new-thread-workspace", | ||
| 191 | title: "Project new-thread workspace", | ||
| 192 | to: "/settings/projects", | ||
| 193 | }, | ||
| 189 | { | 194 | { |
| 190 | id: "project-scripts", | 195 | id: "project-scripts", |
| 191 | title: "Project scripts", | 196 | title: "Project scripts", |
apps/web/src/hooks/useHandleNewThread.ts+108-32| ... | @@ -20,13 +20,34 @@ import { | ... | @@ -20,13 +20,34 @@ import { |
| 20 | getProjectOrderKey, | 20 | getProjectOrderKey, |
| 21 | selectProjectGroupingSettings, | 21 | selectProjectGroupingSettings, |
| 22 | } from "../logicalProject"; | 22 | } from "../logicalProject"; |
| 23 | import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; | ||
| 23 | import { readThreadShell, useProjects, useThread } from "../state/entities"; | 24 | import { readThreadShell, useProjects, useThread } from "../state/entities"; |
| 24 | import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; | 25 | import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; |
| 26 | import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; | ||
| 25 | import { primaryServerSettingsAtom } from "../state/server"; | 27 | import { primaryServerSettingsAtom } from "../state/server"; |
| 26 | import { resolveThreadRouteTarget } from "../threadRoutes"; | 28 | import { resolveThreadRouteTarget } from "../threadRoutes"; |
| 27 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; | 29 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; |
| 28 | import { useClientSettings } from "./useSettings"; | 30 | import { useClientSettings } from "./useSettings"; |
| 29 | 31 | ||
| 32 | interface NewThreadWorkspaceOptions { | ||
| 33 | branch?: string | null; | ||
| 34 | worktreePath?: string | null; | ||
| 35 | envMode?: DraftThreadEnvMode; | ||
| 36 | startFromOrigin?: boolean; | ||
| 37 | } | ||
| 38 | |||
| 39 | // The workspace options the caller passed explicitly, shaped for the draft | ||
| 40 | // store: absent keys stay absent so they never overwrite existing draft | ||
| 41 | // state. Every reuse path applies exactly this set. | ||
| 42 | function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undefined) { | ||
| 43 | return { | ||
| 44 | ...(options?.branch !== undefined ? { branch: options.branch } : {}), | ||
| 45 | ...(options?.worktreePath !== undefined ? { worktreePath: options.worktreePath } : {}), | ||
| 46 | ...(options?.envMode !== undefined ? { envMode: options.envMode } : {}), | ||
| 47 | ...(options?.startFromOrigin !== undefined ? { startFromOrigin: options.startFromOrigin } : {}), | ||
| 48 | }; | ||
| 49 | } | ||
| 50 | |||
| 30 | export function useNewThreadHandler() { | 51 | export function useNewThreadHandler() { |
| 31 | const projects = useProjects(); | 52 | const projects = useProjects(); |
| 32 | // New-thread defaults are a user preference, and the settings UI only ever | 53 | // New-thread defaults are a user preference, and the settings UI only ever |
| ... | @@ -105,6 +126,22 @@ export function useNewThreadHandler() { | ... | @@ -105,6 +126,22 @@ export function useNewThreadHandler() { |
| 105 | candidate.id === projectRef.projectId && | 126 | candidate.id === projectRef.projectId && |
| 106 | candidate.environmentId === projectRef.environmentId, | 127 | candidate.environmentId === projectRef.environmentId, |
| 107 | ); | 128 | ); |
| 129 | // The shared resolver owns the priority order. The t3.json read is | ||
| 130 | // skipped entirely when a higher-priority source decides, and its | ||
| 131 | // query atom caches per project after the first call. | ||
| 132 | const resolveDefaultEnvMode = async (): Promise<DraftThreadEnvMode> => { | ||
| 133 | const consultProjectFile = project !== undefined && project.defaultThreadEnvMode == null; | ||
| 134 | return resolveDefaultThreadEnvMode({ | ||
| 135 | projectSetting: project?.defaultThreadEnvMode, | ||
| 136 | projectFile: consultProjectFile | ||
| 137 | ? await readT3ProjectFileDefaultThreadEnvMode( | ||
| 138 | project.environmentId, | ||
| 139 | project.workspaceRoot, | ||
| 140 | ) | ||
| 141 | : null, | ||
| 142 | globalDefault: primaryServerSettings.defaultThreadEnvMode, | ||
| 143 | }); | ||
| 144 | }; | ||
| 108 | const logicalProjectKey = project | 145 | const logicalProjectKey = project |
| 109 | ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) | 146 | ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) |
| 110 | : scopedProjectKey(projectRef); | 147 | : scopedProjectKey(projectRef); |
| ... | @@ -146,25 +183,37 @@ export function useNewThreadHandler() { | ... | @@ -146,25 +183,37 @@ export function useNewThreadHandler() { |
| 146 | // preserved. When the draft is already open and no options were | 183 | // preserved. When the draft is already open and no options were |
| 147 | // passed, leave it alone entirely — the user may have just picked a | 184 | // passed, leave it alone entirely — the user may have just picked a |
| 148 | // branch in the composer. | 185 | // branch in the composer. |
| 149 | const defaultEnvMode = primaryServerSettings.defaultThreadEnvMode; | 186 | let workspaceContext: NewThreadWorkspaceOptions | null = null; |
| 150 | const workspaceContext = hasExplicitWorkspaceOption | 187 | if (hasExplicitWorkspaceOption) { |
| 151 | ? { | 188 | workspaceContext = pickExplicitWorkspaceOptions(options); |
| 152 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), | 189 | } else if (!isDraftAlreadyOpen) { |
| 153 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), | 190 | const defaultEnvMode = await resolveDefaultEnvMode(); |
| 154 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), | 191 | // The await yields. If the draft was opened (a concurrent |
| 155 | ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), | 192 | // invocation's navigation landed) or promoted to a real thread |
| 156 | } | 193 | // in the meantime, this invocation is a stale loser: resetting |
| 157 | : isDraftAlreadyOpen | 194 | // context, remapping (which wipes branch/worktree on a member |
| 158 | ? null | 195 | // change), or navigating would all clobber state written after |
| 159 | : { | 196 | // the snapshot above. Bail out entirely — the winner already |
| 160 | branch: null, | 197 | // did this work. |
| 161 | worktreePath: null, | 198 | const routeTargetNow = getCurrentRouteTarget(); |
| 162 | envMode: defaultEnvMode, | 199 | const openedMeanwhile = |
| 163 | startFromOrigin: resolveNewDraftStartFromOrigin({ | 200 | routeTargetNow?.kind === "draft" && |
| 164 | envMode: defaultEnvMode, | 201 | routeTargetNow.draftId === reusableStoredDraftThread.draftId; |
| 165 | newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, | 202 | const promotedMeanwhile = |
| 166 | }), | 203 | storedDraftThreadRef !== null && readThreadShell(storedDraftThreadRef) !== null; |
| 167 | }; | 204 | if (openedMeanwhile || promotedMeanwhile) { |
| 205 | return; | ||
| 206 | } | ||
| 207 | workspaceContext = { | ||
| 208 | branch: null, | ||
| 209 | worktreePath: null, | ||
| 210 | envMode: defaultEnvMode, | ||
| 211 | startFromOrigin: resolveNewDraftStartFromOrigin({ | ||
| 212 | envMode: defaultEnvMode, | ||
| 213 | newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, | ||
| 214 | }), | ||
| 215 | }; | ||
| 216 | } | ||
| 168 | if (workspaceContext) { | 217 | if (workspaceContext) { |
| 169 | setDraftThreadContext(reusableStoredDraftThread.draftId, { | 218 | setDraftThreadContext(reusableStoredDraftThread.draftId, { |
| 170 | ...workspaceContext, | 219 | ...workspaceContext, |
| ... | @@ -195,9 +244,13 @@ export function useNewThreadHandler() { | ... | @@ -195,9 +244,13 @@ export function useNewThreadHandler() { |
| 195 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), | 244 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), |
| 196 | }, | 245 | }, |
| 197 | ); | 246 | ); |
| 247 | // Re-read the route: the snapshot from before the await is stale | ||
| 248 | // once a concurrent invocation's navigation lands, and navigating | ||
| 249 | // again would push a duplicate history entry. | ||
| 250 | const routeTargetAfterWrites = getCurrentRouteTarget(); | ||
| 198 | if ( | 251 | if ( |
| 199 | currentRouteTarget?.kind === "draft" && | 252 | routeTargetAfterWrites?.kind === "draft" && |
| 200 | currentRouteTarget.draftId === reusableStoredDraftThread.draftId | 253 | routeTargetAfterWrites.draftId === reusableStoredDraftThread.draftId |
| 201 | ) { | 254 | ) { |
| 202 | return; | 255 | return; |
| 203 | } | 256 | } |
| ... | @@ -221,22 +274,14 @@ export function useNewThreadHandler() { | ... | @@ -221,22 +274,14 @@ export function useNewThreadHandler() { |
| 221 | hasEnvModeOption || | 274 | hasEnvModeOption || |
| 222 | hasStartFromOriginOption | 275 | hasStartFromOriginOption |
| 223 | ) { | 276 | ) { |
| 224 | setDraftThreadContext(currentRouteTarget.draftId, { | 277 | setDraftThreadContext(currentRouteTarget.draftId, pickExplicitWorkspaceOptions(options)); |
| 225 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), | ||
| 226 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), | ||
| 227 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), | ||
| 228 | ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), | ||
| 229 | }); | ||
| 230 | } | 278 | } |
| 231 | setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { | 279 | setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { |
| 232 | threadId: latestActiveDraftThread.threadId, | 280 | threadId: latestActiveDraftThread.threadId, |
| 233 | createdAt: latestActiveDraftThread.createdAt, | 281 | createdAt: latestActiveDraftThread.createdAt, |
| 234 | runtimeMode: latestActiveDraftThread.runtimeMode, | 282 | runtimeMode: latestActiveDraftThread.runtimeMode, |
| 235 | interactionMode: latestActiveDraftThread.interactionMode, | 283 | interactionMode: latestActiveDraftThread.interactionMode, |
| 236 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), | 284 | ...pickExplicitWorkspaceOptions(options), |
| 237 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), | ||
| 238 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), | ||
| 239 | ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), | ||
| 240 | }); | 285 | }); |
| 241 | return Promise.resolve(); | 286 | return Promise.resolve(); |
| 242 | } | 287 | } |
| ... | @@ -244,8 +289,39 @@ export function useNewThreadHandler() { | ... | @@ -244,8 +289,39 @@ export function useNewThreadHandler() { |
| 244 | const draftId = newDraftId(); | 289 | const draftId = newDraftId(); |
| 245 | const threadId = newThreadId(); | 290 | const threadId = newThreadId(); |
| 246 | const createdAt = new Date().toISOString(); | 291 | const createdAt = new Date().toISOString(); |
| 247 | const initialEnvMode = options?.envMode ?? primaryServerSettings.defaultThreadEnvMode; | ||
| 248 | return (async () => { | 292 | return (async () => { |
| 293 | const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode()); | ||
| 294 | // The await yields, so a concurrent invocation may have registered a | ||
| 295 | // draft for this logical project in the meantime. Registering ours | ||
| 296 | // too would evict that draft while its navigation is in flight — | ||
| 297 | // reuse the winner instead, like the synchronous path above does. | ||
| 298 | const racedDraft = getDraftSessionByLogicalProjectKey(logicalProjectKey); | ||
| 299 | if ( | ||
| 300 | racedDraft && | ||
| 301 | readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null | ||
| 302 | ) { | ||
| 303 | // Same remap the reuse paths above perform: point the draft at the | ||
| 304 | // caller's project member and apply explicit workspace options if | ||
| 305 | // the caller passed any. Without explicit options the winner's | ||
| 306 | // context stands untouched — the winner's navigation is landing, | ||
| 307 | // which is the isDraftAlreadyOpen "leave it alone" case. Writing | ||
| 308 | // this invocation's defaults here instead would clobber the | ||
| 309 | // winner's explicit picks and could pair its worktreePath with a | ||
| 310 | // contradictory envMode. | ||
| 311 | setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, racedDraft.draftId, { | ||
| 312 | threadId: racedDraft.threadId, | ||
| 313 | createdAt: racedDraft.createdAt, | ||
| 314 | runtimeMode: racedDraft.runtimeMode, | ||
| 315 | interactionMode: racedDraft.interactionMode, | ||
| 316 | ...pickExplicitWorkspaceOptions(options), | ||
| 317 | }); | ||
| 318 | await router.navigate({ | ||
| 319 | to: "/draft/$draftId", | ||
| 320 | params: { draftId: racedDraft.draftId }, | ||
| 321 | replace: options?.replace ?? false, | ||
| 322 | }); | ||
| 323 | return; | ||
| 324 | } | ||
| 249 | setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { | 325 | setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { |
| 250 | threadId, | 326 | threadId, |
| 251 | createdAt, | 327 | createdAt, |
apps/web/src/hooks/useT3ProjectFileScripts.ts+13-10| ... | @@ -1,17 +1,14 @@ | ... | @@ -1,17 +1,14 @@ |
| 1 | import { | 1 | import { |
| 2 | T3_PROJECT_FILE_NAME, | 2 | T3_PROJECT_FILE_NAME, |
| 3 | type EnvironmentId, | 3 | type EnvironmentId, |
| 4 | type T3ProjectFile, | ||
| 4 | type T3ProjectFileScript, | 5 | type T3ProjectFileScript, |
| 5 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 6 | import { T3ProjectFileFromJson } from "@t3tools/shared/t3ProjectFile"; | 7 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; |
| 7 | import * as Exit from "effect/Exit"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | import { useMemo } from "react"; | 8 | import { useMemo } from "react"; |
| 10 | 9 | ||
| 11 | import { useProjectFileQuery } from "~/components/files/projectFilesQueryState"; | 10 | import { useProjectFileQuery } from "~/components/files/projectFilesQueryState"; |
| 12 | 11 | ||
| 13 | const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson); | ||
| 14 | |||
| 15 | const NO_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = []; | 12 | const NO_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = []; |
| 16 | 13 | ||
| 17 | export interface T3ProjectFileState { | 14 | export interface T3ProjectFileState { |
| ... | @@ -23,6 +20,8 @@ export interface T3ProjectFileState { | ... | @@ -23,6 +20,8 @@ export interface T3ProjectFileState { |
| 23 | * - `loading`: the file query has not settled yet. | 20 | * - `loading`: the file query has not settled yet. |
| 24 | */ | 21 | */ |
| 25 | status: "loading" | "missing" | "invalid" | "valid"; | 22 | status: "loading" | "missing" | "invalid" | "valid"; |
| 23 | /** The decoded file when status is `valid`, null otherwise. */ | ||
| 24 | file: T3ProjectFile | null; | ||
| 26 | scripts: ReadonlyArray<T3ProjectFileScript>; | 25 | scripts: ReadonlyArray<T3ProjectFileScript>; |
| 27 | } | 26 | } |
| 28 | 27 | ||
| ... | @@ -39,13 +38,17 @@ export function useT3ProjectFileState( | ... | @@ -39,13 +38,17 @@ export function useT3ProjectFileState( |
| 39 | const isPending = query.isPending; | 38 | const isPending = query.isPending; |
| 40 | return useMemo(() => { | 39 | return useMemo(() => { |
| 41 | if (contents === null) { | 40 | if (contents === null) { |
| 42 | return { status: isPending ? "loading" : "missing", scripts: NO_SCRIPTS } as const; | 41 | return { |
| 42 | status: isPending ? "loading" : "missing", | ||
| 43 | file: null, | ||
| 44 | scripts: NO_SCRIPTS, | ||
| 45 | } as const; | ||
| 43 | } | 46 | } |
| 44 | const decoded = decodeT3ProjectFile(contents); | 47 | const file = parseT3ProjectFile(contents); |
| 45 | if (Exit.isFailure(decoded)) { | 48 | if (file === null) { |
| 46 | return { status: "invalid", scripts: NO_SCRIPTS } as const; | 49 | return { status: "invalid", file: null, scripts: NO_SCRIPTS } as const; |
| 47 | } | 50 | } |
| 48 | return { status: "valid", scripts: decoded.value.scripts ?? NO_SCRIPTS } as const; | 51 | return { status: "valid", file, scripts: file.scripts ?? NO_SCRIPTS } as const; |
| 49 | }, [contents, isPending]); | 52 | }, [contents, isPending]); |
| 50 | } | 53 | } |
| 51 | 54 |
apps/web/src/lib/t3ProjectFileDefaults.ts created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | import { T3_PROJECT_FILE_NAME, type EnvironmentId, type ThreadEnvMode } from "@t3tools/contracts"; | ||
| 2 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; | ||
| 3 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | getProjectFileQueryAtom, | ||
| 7 | resolveProjectFileQueryData, | ||
| 8 | } from "~/components/files/projectFilesQueryState"; | ||
| 9 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | ||
| 10 | |||
| 11 | /** | ||
| 12 | * Read `defaultThreadEnvMode` from the project's checked-in `t3.json`. | ||
| 13 | * | ||
| 14 | * Imperative counterpart to `useT3ProjectFileScripts` for the new-thread | ||
| 15 | * path, which resolves defaults at call time rather than render time. The | ||
| 16 | * file query atom caches per (environment, cwd), so repeat calls don't | ||
| 17 | * re-fetch. Optimistic in-app writes overlay the query result, matching what | ||
| 18 | * `useProjectFileQuery` renders. Missing, truncated, or invalid files | ||
| 19 | * resolve to null. | ||
| 20 | */ | ||
| 21 | export async function readT3ProjectFileDefaultThreadEnvMode( | ||
| 22 | environmentId: EnvironmentId, | ||
| 23 | workspaceRoot: string, | ||
| 24 | ): Promise<ThreadEnvMode | null> { | ||
| 25 | const result = await executeAtomQuery( | ||
| 26 | appAtomRegistry, | ||
| 27 | getProjectFileQueryAtom(environmentId, workspaceRoot, T3_PROJECT_FILE_NAME), | ||
| 28 | { reportDefect: false, reportFailure: false }, | ||
| 29 | ); | ||
| 30 | const data = resolveProjectFileQueryData( | ||
| 31 | environmentId, | ||
| 32 | workspaceRoot, | ||
| 33 | T3_PROJECT_FILE_NAME, | ||
| 34 | result._tag === "Success" ? result.value : null, | ||
| 35 | ); | ||
| 36 | if (data === null || data.truncated) return null; | ||
| 37 | return parseT3ProjectFile(data.contents)?.defaultThreadEnvMode ?? null; | ||
| 38 | } | ||
packages/contracts/src/environment.ts+8| ... | @@ -18,6 +18,14 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({ | ... | @@ -18,6 +18,14 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({ |
| 18 | os: ExecutionEnvironmentPlatformOs, | 18 | os: ExecutionEnvironmentPlatformOs, |
| 19 | arch: ExecutionEnvironmentPlatformArch, | 19 | arch: ExecutionEnvironmentPlatformArch, |
| 20 | }); | 20 | }); |
| 21 | |||
| 22 | /** | ||
| 23 | * Where a new thread runs: the project's current checkout ("local") or a | ||
| 24 | * fresh git worktree ("worktree"). Lives here (not settings.ts) so | ||
| 25 | * orchestration contracts can reference it without an import cycle. | ||
| 26 | */ | ||
| 27 | export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); | ||
| 28 | export type ThreadEnvMode = typeof ThreadEnvMode.Type; | ||
| 21 | export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; | 29 | export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; |
| 22 | 30 | ||
| 23 | /** How a server can replace itself with another version when asked over RPC. | 31 | /** How a server can replace itself with another version when asked over RPC. |
packages/contracts/src/orchestration.ts+8-1| ... | @@ -4,7 +4,7 @@ import * as SchemaIssue from "effect/SchemaIssue"; | ... | @@ -4,7 +4,7 @@ import * as SchemaIssue from "effect/SchemaIssue"; |
| 4 | import * as SchemaTransformation from "effect/SchemaTransformation"; | 4 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 5 | import * as Struct from "effect/Struct"; | 5 | import * as Struct from "effect/Struct"; |
| 6 | import { ProviderOptionSelections } from "./model.ts"; | 6 | import { ProviderOptionSelections } from "./model.ts"; |
| 7 | import { RepositoryIdentity } from "./environment.ts"; | 7 | import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; |
| 8 | import { | 8 | import { |
| 9 | ApprovalRequestId, | 9 | ApprovalRequestId, |
| 10 | CheckpointRef, | 10 | CheckpointRef, |
| ... | @@ -217,6 +217,9 @@ export const OrchestrationProject = Schema.Struct({ | ... | @@ -217,6 +217,9 @@ export const OrchestrationProject = Schema.Struct({ |
| 217 | workspaceRoot: TrimmedNonEmptyString, | 217 | workspaceRoot: TrimmedNonEmptyString, |
| 218 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), | 218 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), |
| 219 | defaultModelSelection: Schema.NullOr(ModelSelection), | 219 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 220 | // Per-project override for where new threads start. Null/absent means | ||
| 221 | // "no override": clients fall back to t3.json, then the global setting. | ||
| 222 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | ||
| 220 | scripts: Schema.Array(ProjectScript), | 223 | scripts: Schema.Array(ProjectScript), |
| 221 | createdAt: IsoDateTime, | 224 | createdAt: IsoDateTime, |
| 222 | updatedAt: IsoDateTime, | 225 | updatedAt: IsoDateTime, |
| ... | @@ -411,6 +414,7 @@ export const OrchestrationProjectShell = Schema.Struct({ | ... | @@ -411,6 +414,7 @@ export const OrchestrationProjectShell = Schema.Struct({ |
| 411 | workspaceRoot: TrimmedNonEmptyString, | 414 | workspaceRoot: TrimmedNonEmptyString, |
| 412 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), | 415 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), |
| 413 | defaultModelSelection: Schema.NullOr(ModelSelection), | 416 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 417 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | ||
| 414 | scripts: Schema.Array(ProjectScript), | 418 | scripts: Schema.Array(ProjectScript), |
| 415 | createdAt: IsoDateTime, | 419 | createdAt: IsoDateTime, |
| 416 | updatedAt: IsoDateTime, | 420 | updatedAt: IsoDateTime, |
| ... | @@ -623,6 +627,8 @@ const ProjectMetaUpdateCommand = Schema.Struct({ | ... | @@ -623,6 +627,8 @@ const ProjectMetaUpdateCommand = Schema.Struct({ |
| 623 | title: Schema.optional(TrimmedNonEmptyString), | 627 | title: Schema.optional(TrimmedNonEmptyString), |
| 624 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), | 628 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), |
| 625 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), | 629 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 630 | // Absent = leave unchanged; null = clear the override. | ||
| 631 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | ||
| 626 | scripts: Schema.optional(Schema.Array(ProjectScript)), | 632 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 627 | }); | 633 | }); |
| 628 | 634 | ||
| ... | @@ -1078,6 +1084,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ | ... | @@ -1078,6 +1084,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ |
| 1078 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), | 1084 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), |
| 1079 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), | 1085 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), |
| 1080 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), | 1086 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 1087 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | ||
| 1081 | scripts: Schema.optional(Schema.Array(ProjectScript)), | 1088 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 1082 | updatedAt: IsoDateTime, | 1089 | updatedAt: IsoDateTime, |
| 1083 | }); | 1090 | }); |
packages/contracts/src/settings.ts+4-2| ... | @@ -3,6 +3,7 @@ import * as Duration from "effect/Duration"; | ... | @@ -3,6 +3,7 @@ import * as Duration from "effect/Duration"; |
| 3 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 4 | import * as SchemaTransformation from "effect/SchemaTransformation"; | 4 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 5 | import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; | 5 | import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; |
| 6 | import { ThreadEnvMode } from "./environment.ts"; | ||
| 6 | import { | 7 | import { |
| 7 | DEFAULT_TEXT_GENERATION_MODEL, | 8 | DEFAULT_TEXT_GENERATION_MODEL, |
| 8 | DEFAULT_TEXT_GENERATION_REASONING_EFFORT, | 9 | DEFAULT_TEXT_GENERATION_REASONING_EFFORT, |
| ... | @@ -206,8 +207,9 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS | ... | @@ -206,8 +207,9 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS |
| 206 | 207 | ||
| 207 | // ── Server Settings (server-authoritative) ──────────────────── | 208 | // ── Server Settings (server-authoritative) ──────────────────── |
| 208 | 209 | ||
| 209 | export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); | 210 | // Moved to environment.ts so orchestration contracts can use it without an |
| 210 | export type ThreadEnvMode = typeof ThreadEnvMode.Type; | 211 | // import cycle; re-exported here for compatibility with deep imports. |
| 212 | export { ThreadEnvMode } from "./environment.ts"; | ||
| 211 | 213 | ||
| 212 | const makeBinaryPathSetting = (fallback: string) => | 214 | const makeBinaryPathSetting = (fallback: string) => |
| 213 | TrimmedString.pipe( | 215 | TrimmedString.pipe( |
packages/contracts/src/t3ProjectFile.test.ts+6| ... | @@ -52,4 +52,10 @@ describe("T3ProjectFile", () => { | ... | @@ -52,4 +52,10 @@ describe("T3ProjectFile", () => { |
| 52 | decode({ scripts: [{ name: "Dev", command: "pnpm dev", icon: "rocket" }] }), | 52 | decode({ scripts: [{ name: "Dev", command: "pnpm dev", icon: "rocket" }] }), |
| 53 | ).toThrow(); | 53 | ).toThrow(); |
| 54 | }); | 54 | }); |
| 55 | |||
| 56 | it("decodes defaultThreadEnvMode and rejects unknown modes", () => { | ||
| 57 | expect(decode({ defaultThreadEnvMode: "worktree" }).defaultThreadEnvMode).toBe("worktree"); | ||
| 58 | expect(decode({ defaultThreadEnvMode: "local" }).defaultThreadEnvMode).toBe("local"); | ||
| 59 | expect(() => decode({ defaultThreadEnvMode: "remote" })).toThrow(); | ||
| 60 | }); | ||
| 55 | }); | 61 | }); |
packages/contracts/src/t3ProjectFile.ts+7| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import * as SchemaTransformation from "effect/SchemaTransformation"; | 2 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 3 | 3 | ||
| 4 | import { ThreadEnvMode } from "./environment.ts"; | ||
| 4 | import { ProjectScriptIcon } from "./orchestration.ts"; | 5 | import { ProjectScriptIcon } from "./orchestration.ts"; |
| 5 | 6 | ||
| 6 | /** File name of the checked-in T3 project file, resolved at the workspace root. */ | 7 | /** File name of the checked-in T3 project file, resolved at the workspace root. */ |
| ... | @@ -73,6 +74,12 @@ export const T3ProjectFile = Schema.Struct({ | ... | @@ -73,6 +74,12 @@ export const T3ProjectFile = Schema.Struct({ |
| 73 | T3_PROJECT_FILE_PATH_MAX_LENGTH, | 74 | T3_PROJECT_FILE_PATH_MAX_LENGTH, |
| 74 | ), | 75 | ), |
| 75 | ), | 76 | ), |
| 77 | defaultThreadEnvMode: Schema.optionalKey( | ||
| 78 | ThreadEnvMode.annotate({ | ||
| 79 | description: | ||
| 80 | 'Where new threads start for this repository: "worktree" for a fresh git worktree, "local" for the current checkout. A per-project setting in T3 Code overrides this; when neither is set, the global default applies.', | ||
| 81 | }), | ||
| 82 | ), | ||
| 76 | scripts: Schema.optionalKey( | 83 | scripts: Schema.optionalKey( |
| 77 | Schema.Array(T3ProjectFileScript) | 84 | Schema.Array(T3ProjectFileScript) |
| 78 | .annotate({ | 85 | .annotate({ |
packages/shared/package.json+4| ... | @@ -91,6 +91,10 @@ | ... | @@ -91,6 +91,10 @@ |
| 91 | "types": "./src/projectScripts.ts", | 91 | "types": "./src/projectScripts.ts", |
| 92 | "import": "./src/projectScripts.ts" | 92 | "import": "./src/projectScripts.ts" |
| 93 | }, | 93 | }, |
| 94 | "./threadEnvMode": { | ||
| 95 | "types": "./src/threadEnvMode.ts", | ||
| 96 | "import": "./src/threadEnvMode.ts" | ||
| 97 | }, | ||
| 94 | "./t3ProjectFile": { | 98 | "./t3ProjectFile": { |
| 95 | "types": "./src/t3ProjectFile.ts", | 99 | "types": "./src/t3ProjectFile.ts", |
| 96 | "import": "./src/t3ProjectFile.ts" | 100 | "import": "./src/t3ProjectFile.ts" |
packages/shared/src/t3ProjectFile.test.ts+25-2| ... | @@ -1,7 +1,11 @@ | ... | @@ -1,7 +1,11 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { buildT3ProjectFileJsonSchema, T3ProjectFileFromJson } from "./t3ProjectFile.ts"; | 4 | import { |
| 5 | buildT3ProjectFileJsonSchema, | ||
| 6 | parseT3ProjectFile, | ||
| 7 | T3ProjectFileFromJson, | ||
| 8 | } from "./t3ProjectFile.ts"; | ||
| 5 | 9 | ||
| 6 | const decodeJson = Schema.decodeUnknownSync(T3ProjectFileFromJson); | 10 | const decodeJson = Schema.decodeUnknownSync(T3ProjectFileFromJson); |
| 7 | 11 | ||
| ... | @@ -27,9 +31,15 @@ describe("buildT3ProjectFileJsonSchema", () => { | ... | @@ -27,9 +31,15 @@ describe("buildT3ProjectFileJsonSchema", () => { |
| 27 | required?: ReadonlyArray<string>; | 31 | required?: ReadonlyArray<string>; |
| 28 | }; | 32 | }; |
| 29 | 33 | ||
| 30 | expect(Object.keys(schema.properties).sort()).toEqual(["$schema", "iconPath", "scripts"]); | 34 | expect(Object.keys(schema.properties).sort()).toEqual([ |
| 35 | "$schema", | ||
| 36 | "defaultThreadEnvMode", | ||
| 37 | "iconPath", | ||
| 38 | "scripts", | ||
| 39 | ]); | ||
| 31 | expect(schema.required).toBeUndefined(); | 40 | expect(schema.required).toBeUndefined(); |
| 32 | expect(schema.properties.iconPath?.description).toContain("Workspace-relative path"); | 41 | expect(schema.properties.iconPath?.description).toContain("Workspace-relative path"); |
| 42 | expect(schema.properties.defaultThreadEnvMode?.description).toContain("new threads start"); | ||
| 33 | 43 | ||
| 34 | const script = schema.properties.scripts?.items; | 44 | const script = schema.properties.scripts?.items; |
| 35 | expect(script?.required).toEqual(["name", "command"]); | 45 | expect(script?.required).toEqual(["name", "command"]); |
| ... | @@ -67,3 +77,16 @@ describe("T3ProjectFileFromJson", () => { | ... | @@ -67,3 +77,16 @@ describe("T3ProjectFileFromJson", () => { |
| 67 | expect(() => decodeJson("{ not json")).toThrow(); | 77 | expect(() => decodeJson("{ not json")).toThrow(); |
| 68 | }); | 78 | }); |
| 69 | }); | 79 | }); |
| 80 | |||
| 81 | describe("parseT3ProjectFile", () => { | ||
| 82 | it("returns the decoded file for valid contents", () => { | ||
| 83 | expect(parseT3ProjectFile('{ "defaultThreadEnvMode": "worktree" }')).toEqual({ | ||
| 84 | defaultThreadEnvMode: "worktree", | ||
| 85 | }); | ||
| 86 | }); | ||
| 87 | |||
| 88 | it("returns null for malformed or invalid contents", () => { | ||
| 89 | expect(parseT3ProjectFile("{ not json")).toBeNull(); | ||
| 90 | expect(parseT3ProjectFile('{ "defaultThreadEnvMode": "spaceship" }')).toBeNull(); | ||
| 91 | }); | ||
| 92 | }); |
packages/shared/src/t3ProjectFile.ts+13| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as Exit from "effect/Exit"; | ||
| 1 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 2 | 3 | ||
| 3 | import { T3ProjectFile, T3_PROJECT_FILE_SCHEMA_URL } from "@t3tools/contracts"; | 4 | import { T3ProjectFile, T3_PROJECT_FILE_SCHEMA_URL } from "@t3tools/contracts"; |
| ... | @@ -10,6 +11,18 @@ import { fromLenientJson } from "./schemaJson.ts"; | ... | @@ -10,6 +11,18 @@ import { fromLenientJson } from "./schemaJson.ts"; |
| 10 | */ | 11 | */ |
| 11 | export const T3ProjectFileFromJson = fromLenientJson(T3ProjectFile); | 12 | export const T3ProjectFileFromJson = fromLenientJson(T3ProjectFile); |
| 12 | 13 | ||
| 14 | const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson); | ||
| 15 | |||
| 16 | /** | ||
| 17 | * Decode raw `t3.json` contents, treating invalid or malformed files as | ||
| 18 | * absent. Clients use this to read optional defaults (scripts, thread env | ||
| 19 | * mode) without surfacing decode errors to the user. | ||
| 20 | */ | ||
| 21 | export function parseT3ProjectFile(contents: string): T3ProjectFile | null { | ||
| 22 | const decoded = decodeT3ProjectFile(contents); | ||
| 23 | return Exit.isSuccess(decoded) ? decoded.value : null; | ||
| 24 | } | ||
| 25 | |||
| 13 | /** | 26 | /** |
| 14 | * Build the publishable JSON Schema document for `t3.json` (draft 2020-12). | 27 | * Build the publishable JSON Schema document for `t3.json` (draft 2020-12). |
| 15 | * | 28 | * |
packages/shared/src/threadEnvMode.test.ts created+65| ... | @@ -0,0 +1,65 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { isDefaultThreadEnvModeSettled, resolveDefaultThreadEnvMode } from "./threadEnvMode.ts"; | ||
| 4 | |||
| 5 | describe("resolveDefaultThreadEnvMode", () => { | ||
| 6 | it("prefers the project setting over t3.json over the global default", () => { | ||
| 7 | expect( | ||
| 8 | resolveDefaultThreadEnvMode({ | ||
| 9 | projectSetting: "local", | ||
| 10 | projectFile: "worktree", | ||
| 11 | globalDefault: "worktree", | ||
| 12 | }), | ||
| 13 | ).toBe("local"); | ||
| 14 | expect( | ||
| 15 | resolveDefaultThreadEnvMode({ | ||
| 16 | projectSetting: null, | ||
| 17 | projectFile: "local", | ||
| 18 | globalDefault: "worktree", | ||
| 19 | }), | ||
| 20 | ).toBe("local"); | ||
| 21 | expect( | ||
| 22 | resolveDefaultThreadEnvMode({ | ||
| 23 | projectSetting: undefined, | ||
| 24 | projectFile: null, | ||
| 25 | globalDefault: "worktree", | ||
| 26 | }), | ||
| 27 | ).toBe("worktree"); | ||
| 28 | }); | ||
| 29 | }); | ||
| 30 | |||
| 31 | describe("isDefaultThreadEnvModeSettled", () => { | ||
| 32 | it("settles on an explicit pick or project setting even while the file loads", () => { | ||
| 33 | expect( | ||
| 34 | isDefaultThreadEnvModeSettled({ | ||
| 35 | explicitMode: "local", | ||
| 36 | projectSetting: null, | ||
| 37 | projectFilePending: true, | ||
| 38 | }), | ||
| 39 | ).toBe(true); | ||
| 40 | expect( | ||
| 41 | isDefaultThreadEnvModeSettled({ | ||
| 42 | explicitMode: undefined, | ||
| 43 | projectSetting: "worktree", | ||
| 44 | projectFilePending: true, | ||
| 45 | }), | ||
| 46 | ).toBe(true); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("stays unsettled only while a consulted file read is pending", () => { | ||
| 50 | expect( | ||
| 51 | isDefaultThreadEnvModeSettled({ | ||
| 52 | explicitMode: undefined, | ||
| 53 | projectSetting: null, | ||
| 54 | projectFilePending: true, | ||
| 55 | }), | ||
| 56 | ).toBe(false); | ||
| 57 | expect( | ||
| 58 | isDefaultThreadEnvModeSettled({ | ||
| 59 | explicitMode: undefined, | ||
| 60 | projectSetting: null, | ||
| 61 | projectFilePending: false, | ||
| 62 | }), | ||
| 63 | ).toBe(true); | ||
| 64 | }); | ||
| 65 | }); | ||
packages/shared/src/threadEnvMode.ts created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | import type { ThreadEnvMode } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | /** | ||
| 4 | * Canonical priority order for a project's default thread env mode: | ||
| 5 | * per-project setting > checked-in t3.json > global server setting. | ||
| 6 | * | ||
| 7 | * An explicit composer pick outranks all of these; callers apply it before | ||
| 8 | * consulting the defaults. Web resolves the sources imperatively at draft | ||
| 9 | * creation, mobile reactively — both must route through this function so the | ||
| 10 | * platforms cannot disagree on the order. | ||
| 11 | */ | ||
| 12 | export function resolveDefaultThreadEnvMode(sources: { | ||
| 13 | readonly projectSetting: ThreadEnvMode | null | undefined; | ||
| 14 | readonly projectFile: ThreadEnvMode | null | undefined; | ||
| 15 | readonly globalDefault: ThreadEnvMode; | ||
| 16 | }): ThreadEnvMode { | ||
| 17 | return sources.projectSetting ?? sources.projectFile ?? sources.globalDefault; | ||
| 18 | } | ||
| 19 | |||
| 20 | /** | ||
| 21 | * True once the resolved default can no longer change: an explicit pick or a | ||
| 22 | * source that outranks t3.json decided, or the file read settled. While | ||
| 23 | * false, nothing may persist the provisional default (for example into a | ||
| 24 | * draft's workspace selection) — it could differ from the final value. | ||
| 25 | */ | ||
| 26 | export function isDefaultThreadEnvModeSettled(sources: { | ||
| 27 | readonly explicitMode: ThreadEnvMode | undefined; | ||
| 28 | readonly projectSetting: ThreadEnvMode | null | undefined; | ||
| 29 | readonly projectFilePending: boolean; | ||
| 30 | }): boolean { | ||
| 31 | return ( | ||
| 32 | sources.explicitMode !== undefined || | ||
| 33 | sources.projectSetting != null || | ||
| 34 | !sources.projectFilePending | ||
| 35 | ); | ||
| 36 | } | ||