diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 47800f3192d..8f50aa8f882 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -19,6 +19,7 @@ const clientSettings: ClientSettings = { dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, favorites: [], + glassOpacity: 80, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/marketing/package.json b/apps/marketing/package.json index d122dac3c56..912faf88164 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -10,6 +10,7 @@ "typecheck": "astro check" }, "dependencies": { + "@t3tools/shared": "workspace:*", "astro": "^7.0.3" }, "devDependencies": { diff --git a/apps/marketing/src/pages/schema/t3.json.ts b/apps/marketing/src/pages/schema/t3.json.ts new file mode 100644 index 00000000000..707523eafc5 --- /dev/null +++ b/apps/marketing/src/pages/schema/t3.json.ts @@ -0,0 +1,10 @@ +import type { APIRoute } from "astro"; + +import { buildT3ProjectFileJsonSchema } from "@t3tools/shared/t3ProjectFile"; + +// Rendered at build time; published at https://t3.codes/schema/t3.json so +// t3.json files can reference it via "$schema" for editor/LSP support. +export const GET: APIRoute = () => + new Response(`${JSON.stringify(buildT3ProjectFileJsonSchema(), null, 2)}\n`, { + headers: { "Content-Type": "application/json" }, + }); diff --git a/apps/marketing/vercel.ts b/apps/marketing/vercel.ts index c7344e9ff78..d2a3c774b8a 100644 --- a/apps/marketing/vercel.ts +++ b/apps/marketing/vercel.ts @@ -1,7 +1,7 @@ import type { VercelConfig } from "@vercel/config/v1"; export const config: VercelConfig = { - installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing'", + installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'", buildCommand: "vp run --filter @t3tools/marketing build", outputDirectory: "dist", }; diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ce81db8c486..e328bcef00e 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -582,10 +582,13 @@ export function NewTaskDraftScreen(props: { ? "Approve actions" : flow.runtimeMode === "auto-accept-edits" ? "Auto-accept edits" - : "Full access", + : flow.runtimeMode === "auto" + ? "Auto" + : "Full access", subactions: [ { id: "options:runtime:approval-required", title: "Approve actions" }, { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, + { id: "options:runtime:auto", title: "Auto" }, { id: "options:runtime:full-access", title: "Full access" }, ].map((option) => { const value = option.id.replace("options:runtime:", ""); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index c1a6f2c235e..59af7199af7 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -638,10 +638,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ? "Approve actions" : currentRuntimeMode === "auto-accept-edits" ? "Auto-accept edits" - : "Full access", + : currentRuntimeMode === "auto" + ? "Auto" + : "Full access", subactions: [ { id: "options:runtime:approval-required", title: "Approve actions" }, { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, + { id: "options:runtime:auto", title: "Auto" }, { id: "options:runtime:full-access", title: "Full access" }, ].map((option) => { const value = option.id.replace("options:runtime:", ""); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 06a22754e55..42fd3f900e5 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; +import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; @@ -20,7 +21,10 @@ const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { const testLayer = Layer.mergeAll( configLayer, WorkspacePaths.layer, - ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), + ProjectFaviconResolver.layer.pipe( + Layer.provide(WorkspacePaths.layer), + Layer.provide(T3ProjectFileLoader.layer), + ), ServerSecretStore.layer.pipe(Layer.provide(configLayer)), ).pipe(Layer.provideMerge(NodeServices.layer)); diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 0b017b22e4e..75db78844a5 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -8,9 +8,15 @@ import * as PlatformError from "effect/PlatformError"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; +import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; const TestLayer = Layer.empty.pipe( - Layer.provideMerge(ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge( + ProjectFaviconResolver.layer.pipe( + Layer.provide(WorkspacePaths.layer), + Layer.provide(T3ProjectFileLoader.layer), + ), + ), Layer.provideMerge(NodeServices.layer), ); @@ -37,7 +43,7 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => ProjectFaviconResolver.make.pipe( - Effect.provide(WorkspacePaths.layer), + Effect.provide([WorkspacePaths.layer, T3ProjectFileLoader.layer]), Effect.provideService(FileSystem.FileSystem, fileSystem), ); @@ -56,6 +62,63 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); + it.effect("prefers a t3.json iconPath over well-known files", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/mark.svg" }'); + yield* writeTextFile(cwd, "brand/mark.svg", "mark"); + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("brand/mark.svg"); + }), + ); + + it.effect("falls back to well-known files when the t3.json iconPath does not exist", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/missing.svg" }'); + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("favicon.svg"); + }), + ); + + it.effect("ignores invalid t3.json files", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "t3.json", "{ not json"); + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("favicon.svg"); + }), + ); + + it.effect("does not resolve a t3.json iconPath outside the workspace root", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const parent = yield* makeTempDir; + const cwd = `${parent}/app`; + yield* writeTextFile(parent, "secret.svg", "secret"); + yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "../secret.svg" }'); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).toBeNull(); + }), + ); + it.effect("resolves icon hrefs from project source files", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index e644df06ae6..2c7195de630 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -16,6 +16,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; // Well-known favicon paths checked in order. const FAVICON_CANDIDATES = [ @@ -117,6 +118,7 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader; const resolveIconHref = (href: string): ReadonlyArray => { const clean = href.replace(/^\//, ""); @@ -177,6 +179,15 @@ export const make = Effect.gen(function* () { }), ), ); + // A t3.json iconPath takes precedence over the well-known locations. + const projectFile = yield* projectFileLoader.load(projectCwd); + if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) { + const existing = yield* findExistingFile(projectCwd, [projectFile.value.iconPath]); + if (existing) { + return existing; + } + } + for (const candidate of FAVICON_CANDIDATES) { const existing = yield* findExistingFile(projectCwd, [candidate]); if (existing) { diff --git a/apps/server/src/project/T3ProjectFileLoader.test.ts b/apps/server/src/project/T3ProjectFileLoader.test.ts new file mode 100644 index 00000000000..c4761825387 --- /dev/null +++ b/apps/server/src/project/T3ProjectFileLoader.test.ts @@ -0,0 +1,89 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it, describe, expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; + +const TestLayer = Layer.empty.pipe( + Layer.provideMerge(T3ProjectFileLoader.layer), + Layer.provideMerge(NodeServices.layer), +); + +const makeTempDir = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-project-file-", + }); +}); + +const writeProjectFile = Effect.fn("writeProjectFile")(function* (cwd: string, contents: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.writeFileString(path.join(cwd, "t3.json"), contents).pipe(Effect.orDie); +}); + +it.layer(TestLayer)("T3ProjectFileLoader", (it) => { + describe("load", () => { + it.effect("loads and decodes a valid t3.json", () => + Effect.gen(function* () { + const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; + const cwd = yield* makeTempDir; + yield* writeProjectFile( + cwd, + `{ + // JSONC is tolerated + "iconPath": "assets/logo.svg", + "scripts": [{ "name": "Dev", "command": "pnpm dev" }], + }`, + ); + + const loaded = yield* loader.load(cwd); + + expect(Option.isSome(loaded)).toBe(true); + if (Option.isSome(loaded)) { + expect(loaded.value.iconPath).toBe("assets/logo.svg"); + expect(loaded.value.scripts).toEqual([{ name: "Dev", command: "pnpm dev" }]); + } + }), + ); + + it.effect("returns none when t3.json is missing", () => + Effect.gen(function* () { + const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; + const cwd = yield* makeTempDir; + + const loaded = yield* loader.load(cwd); + + expect(Option.isNone(loaded)).toBe(true); + }), + ); + + it.effect("returns none for malformed JSON without failing", () => + Effect.gen(function* () { + const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; + const cwd = yield* makeTempDir; + yield* writeProjectFile(cwd, "{ not json"); + + const loaded = yield* loader.load(cwd); + + expect(Option.isNone(loaded)).toBe(true); + }), + ); + + it.effect("returns none for schema-invalid files without failing", () => + Effect.gen(function* () { + const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; + const cwd = yield* makeTempDir; + yield* writeProjectFile(cwd, '{ "scripts": [{ "name": "Dev" }] }'); + + const loaded = yield* loader.load(cwd); + + expect(Option.isNone(loaded)).toBe(true); + }), + ); + }); +}); diff --git a/apps/server/src/project/T3ProjectFileLoader.ts b/apps/server/src/project/T3ProjectFileLoader.ts new file mode 100644 index 00000000000..00ad9d3835a --- /dev/null +++ b/apps/server/src/project/T3ProjectFileLoader.ts @@ -0,0 +1,108 @@ +/** + * T3ProjectFileLoader - Effect service that loads the checked-in `t3.json` + * project file from a workspace root. + * + * Loading is best-effort: a missing file resolves to `Option.none`, and + * unreadable or invalid files are logged and treated as absent so callers + * can fall back to their defaults. + * + * @module T3ProjectFileLoader + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { T3_PROJECT_FILE_NAME, type T3ProjectFile } from "@t3tools/contracts"; +import { T3ProjectFileFromJson } from "@t3tools/shared/t3ProjectFile"; + +const decodeT3ProjectFileJson = Schema.decodeEffect(T3ProjectFileFromJson); + +export class T3ProjectFileLoadError extends Schema.TaggedErrorClass()( + "T3ProjectFileLoadError", + { + operation: Schema.Literals(["read", "decode"]), + workspaceRoot: Schema.String, + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} ${T3_PROJECT_FILE_NAME} at ${this.filePath}.`; + } +} + +/** Service tag for t3.json project file loading. */ +export class T3ProjectFileLoader extends Context.Service< + T3ProjectFileLoader, + { + /** + * Load and decode `t3.json` at the workspace root. + * + * Never fails: missing, unreadable, or invalid files resolve to + * `Option.none` (invalid files are logged as warnings). + */ + readonly load: (workspaceRoot: string) => Effect.Effect>; + } +>()("t3/project/T3ProjectFileLoader") {} + +const logT3ProjectFileLoadError = (error: T3ProjectFileLoadError) => + Effect.logWarning(error).pipe( + Effect.annotateLogs({ + operation: error.operation, + workspaceRoot: error.workspaceRoot, + filePath: error.filePath, + errorTag: error._tag, + }), + ); + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const load: T3ProjectFileLoader["Service"]["load"] = Effect.fn("T3ProjectFileLoader.load")( + function* (workspaceRoot) { + const filePath = path.join(workspaceRoot, T3_PROJECT_FILE_NAME); + const raw = yield* fileSystem.readFileString(filePath).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : logT3ProjectFileLoadError( + new T3ProjectFileLoadError({ + operation: "read", + workspaceRoot, + filePath, + cause: error, + }), + ).pipe(Effect.as(Option.none())), + }), + ); + if (Option.isNone(raw)) { + return Option.none(); + } + return yield* decodeT3ProjectFileJson(raw.value).pipe( + Effect.map(Option.some), + Effect.catchTags({ + SchemaError: (error) => + logT3ProjectFileLoadError( + new T3ProjectFileLoadError({ + operation: "decode", + workspaceRoot, + filePath, + cause: error, + }), + ).pipe(Effect.as(Option.none())), + }), + ); + }, + ); + + return T3ProjectFileLoader.of({ load }); +}); + +export const layer = Layer.effect(T3ProjectFileLoader, make); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 2735b69a187..ba8b1b848a8 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -356,6 +356,25 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("derives auto permission mode from auto runtime policy without skip flag", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "auto", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "auto"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("loads Claude filesystem settings sources for SDK sessions", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 757d7a00eb2..285d9dac608 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -3509,6 +3509,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const effectiveEffort = getEffectiveClaudeAgentEffort(effort, modelSelection?.model); const runtimeModeToPermission: Record = { "auto-accept-edits": "acceptEdits", + auto: "auto", "full-access": "bypassPermissions", }; const permissionMode = runtimeModeToPermission[input.runtimeMode]; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 119fa36303a..d7346a0e0db 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -104,6 +104,7 @@ describe("buildTurnStartParams", () => { NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "never", + approvalsReviewer: "user", sandboxPolicy: { type: "dangerFullAccess", }, @@ -149,6 +150,7 @@ describe("buildTurnStartParams", () => { NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "on-request", + approvalsReviewer: "user", sandboxPolicy: { type: "workspaceWrite", }, @@ -193,6 +195,31 @@ describe("buildTurnStartParams", () => { NodeAssert.ok(settings?.developer_instructions?.includes(`as ${DEFAULT_MODEL} with medium`)); }); + it.effect("routes approvals to the auto reviewer in auto mode", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "auto", + prompt: "Ship it", + }); + + NodeAssert.deepStrictEqual(params, { + threadId: "provider-thread-1", + approvalPolicy: "on-request", + approvalsReviewer: "auto_review", + sandboxPolicy: { + type: "workspaceWrite", + }, + input: [ + { + type: "text", + text: "Ship it", + }, + ], + }); + }), + ); + it("omits collaboration mode when interaction mode is absent", () => { const params = Effect.runSync( buildTurnStartParams({ @@ -205,6 +232,7 @@ describe("buildTurnStartParams", () => { NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "untrusted", + approvalsReviewer: "user", sandboxPolicy: { type: "readOnly", }, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 5a81e915e34..67108dd4dbb 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -264,23 +264,35 @@ function readResumeCursorThreadId( function runtimeModeToThreadConfig(input: RuntimeMode): { readonly approvalPolicy: EffectCodexSchema.V2ThreadStartParams__AskForApproval; readonly sandbox: EffectCodexSchema.V2ThreadStartParams__SandboxMode; + // Always explicit: omitting the field on resume keeps the thread's previous + // reviewer, which would leave auto_review sticky after switching modes. + readonly approvalsReviewer: EffectCodexSchema.V2ThreadStartParams__ApprovalsReviewer; } { switch (input) { case "approval-required": return { approvalPolicy: "untrusted", sandbox: "read-only", + approvalsReviewer: "user", }; case "auto-accept-edits": return { approvalPolicy: "on-request", sandbox: "workspace-write", + approvalsReviewer: "user", + }; + case "auto": + return { + approvalPolicy: "on-request", + sandbox: "workspace-write", + approvalsReviewer: "auto_review", }; case "full-access": default: return { approvalPolicy: "never", sandbox: "danger-full-access", + approvalsReviewer: "user", }; } } @@ -296,6 +308,7 @@ function buildThreadStartParams(input: { cwd: input.cwd, approvalPolicy: config.approvalPolicy, sandbox: config.sandbox, + approvalsReviewer: config.approvalsReviewer, ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), }; @@ -310,6 +323,7 @@ function runtimeModeToTurnSandboxPolicy( type: "readOnly", }; case "auto-accept-edits": + case "auto": return { type: "workspaceWrite", }; @@ -382,6 +396,7 @@ export function buildTurnStartParams(input: { threadId: input.threadId, input: turnInput, approvalPolicy: config.approvalPolicy, + approvalsReviewer: config.approvalsReviewer, sandboxPolicy: runtimeModeToTurnSandboxPolicy(input.runtimeMode), ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index eb178f7d8b0..45d03d9dea4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -93,6 +93,7 @@ import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; +import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -503,7 +504,10 @@ const buildAppUnderTest = (options?: { Layer.provide(WorkspacePaths.layer), Layer.provide(workspaceEntriesLayer), ), - ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), + ProjectFaviconResolver.layer.pipe( + Layer.provide(WorkspacePaths.layer), + Layer.provide(T3ProjectFileLoader.layer), + ), ); const gitWorkflowLayer = GitWorkflowService.layer.pipe( Layer.provideMerge(vcsDriverRegistryLayer), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 6a66b2c260b..6da552eb0dd 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -55,6 +55,7 @@ import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; import { ProviderUsageServiceLive } from "./provider/Layers/ProviderUsageService.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; +import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -266,6 +267,7 @@ const WorkspaceLayerLive = Layer.mergeAll( const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( Layer.provide(WorkspacePaths.layer), + Layer.provide(T3ProjectFileLoader.layer), ); const AuthLayerLive = EnvironmentAuth.layer.pipe( diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index ef6b64be9c9..3a70390d0c4 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -106,6 +106,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const pathname = useLocation({ select: (location) => location.pathname }); const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); const useSidebarV2 = sidebarV2Enabled && !isOnSettings; + const useSidebarV2Theme = useSidebarV2 || isOnSettings; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); @@ -165,7 +166,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { side="left" collapsible="offcanvas" data-app-sidebar="" - data-sidebar-version={useSidebarV2 ? "v2" : "v1"} + data-sidebar-version={useSidebarV2Theme ? "v2" : "v1"} className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground" resizable={{ maxWidth: sidebarMaximumWidth, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 0354c2e0cd7..c2db48184c7 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -248,7 +248,7 @@ export const BranchToolbar = memo(function BranchToolbar({ if (!hasActiveThread || !activeProject) return null; return ( -
+
{isMobile ? ( store.threadLastVisitedAtById[routeThreadKey], ); const settings = useEnvironmentSettings(environmentId); + // New-thread defaults live in the primary environment's settings.json (the + // settings UI never writes to remote environments), so read them from the + // primary server rather than the thread's environment. + const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -2320,6 +2329,22 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); + const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< + string | null + >(null); + useEffect(() => { + if (providerStatusBannerKey === null && dismissedProviderStatusBannerKey !== null) { + setDismissedProviderStatusBannerKey(null); + } + }, [dismissedProviderStatusBannerKey, providerStatusBannerKey]); + const visibleProviderStatus = shouldShowProviderStatusBanner( + activeProviderStatus, + dismissedProviderStatusBannerKey, + ) + ? activeProviderStatus + : null; + const hasTimelineTopBanner = Boolean(threadError) || visibleProviderStatus !== null; const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; @@ -3765,7 +3790,7 @@ function ChatViewContent(props: ChatViewProps) { ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? - settings.newWorktreesStartFromOrigin) + primaryServerSettings.newWorktreesStartFromOrigin) : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, @@ -5234,7 +5259,7 @@ function ChatViewContent(props: ChatViewProps) { envMode: mode, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: mode, - newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); @@ -5246,7 +5271,7 @@ function ChatViewContent(props: ChatViewProps) { composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, - settings.newWorktreesStartFromOrigin, + primaryServerSettings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, @@ -5412,7 +5437,7 @@ function ChatViewContent(props: ChatViewProps) {
- {/* Error banner */} - setThreadError(activeThread.id, null)} @@ -5458,6 +5481,13 @@ function ChatViewContent(props: ChatViewProps) {
{/* Chat column */}
+ {/* Provider status overlays the timeline without changing its content height. */} +
+ setDismissedProviderStatusBannerKey(providerStatusBannerKey)} + /> +
{/* Messages Wrapper */}
{/* Messages — LegendList handles virtualization and scrolling internally */} @@ -5494,6 +5524,7 @@ function ChatViewContent(props: ChatViewProps) { onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState} + topFadeEnabled={!hasTimelineTopBanner} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -5526,22 +5557,11 @@ function ChatViewContent(props: ChatViewProps) { : "pointer-events-none absolute inset-x-0 bottom-0 z-20 pt-1.5 sm:pt-2" } > - {!isDraftHeroState ? ( -