From d0b9e9d3c72b83bb92670fc1fa3afac5bf60e4e8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 10:41:48 -0700 Subject: [PATCH 001/536] fix(mobile): the Duo header blur reaches a vertical bar on the left too (#24807) Co-authored-by: Claude Opus 5.5 --- .../ios/T3ScrollEdgeBleedView.swift | 78 ++++++++++++------- docs/internals/mobile-navigation.md | 11 +-- 2 files changed, 55 insertions(+), 34 deletions(-) diff --git a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift index 642a87f14e85..cb3c9041efcc 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift @@ -2,16 +2,18 @@ import ExpoModulesCore import UIKit /// Wraps a screen's header scroll view. Where a column pads its content away from the iPhone Duo's -/// vertical bar, this widens the scroll view under the bar. UIKit draws -/// the header's scroll-edge effect inside the scroll view, so the blur reaches the bar like in the -/// system apps while the content stays in the safe area. Everything happens in UIKit layout, so -/// folds move the blur and the content together. +/// vertical bar, this widens the scroll view under the bar, like the system apps' lists. UIKit +/// draws the header's scroll-edge effect inside the scroll view, so the blur reaches the bar while +/// the content stays in the safe area. Everything happens in UIKit layout, so folds move the blur +/// and the content together. final class T3ScrollEdgeBleedView: ExpoView { // Fabric recycles scroll views, so everything changed here is put back before this view leaves // the window, which unmounting does before any recycling. private var scrollView: UIScrollView? private var container: UIView? - private var bleed: CGFloat = 0 + /// React Native's content view, which every row lives in. + private var content: UIView? + private var bleed = UIEdgeInsets.zero /// Whether the scroll view currently carries this view's changes. private var applied = false /// React Native's clipping, saved while bleeding. React Native only rewrites it when the @@ -55,55 +57,72 @@ final class T3ScrollEdgeBleedView: ExpoView { detach() scrollView = found container = parent + // React Native adds one plain UIView to its scroll view and mounts every child into it. + content = found.subviews.first { type(of: $0) == UIView.self } } bleed = verticalBarBleed() + // Without the content view to shift back, bleeding left would move the rows under the bar. + if content == nil { bleed.left = 0 } apply() } - /// How far the vertical bar's safe area reaches past this view's right edge, when this view sits - /// flush against the padding its column added for it. Nothing else is bled into: not a sidebar - /// column overlapping this one, not a notch, and not a bar on the left, where widening would - /// move the content and need a horizontal offset React Native keeps rewriting. - private func verticalBarBleed() -> CGFloat { - guard verticalBarIsOnTheRight() else { return 0 } + /// How far the vertical bar's safe area reaches past this view's edge, when this view sits flush + /// against the padding its column added for it. Nothing else is bled into: not a sidebar column + /// overlapping this one, and not a notch. + private func verticalBarBleed() -> UIEdgeInsets { + guard let side = verticalBarSide() else { return .zero } var ancestor = superview while let view = ancestor { - let inset = view.safeAreaInsets.right + let inset = side == .left ? view.safeAreaInsets.left : view.safeAreaInsets.right if inset > 0 { let frame = convert(bounds, to: view) - return abs(view.bounds.maxX - inset - frame.maxX) < 0.5 ? inset : 0 + let flush = side == .left + ? abs(frame.minX - (view.bounds.minX + inset)) < 0.5 + : abs(view.bounds.maxX - inset - frame.maxX) < 0.5 + guard flush else { return .zero } + return side == .left + ? UIEdgeInsets(top: 0, left: inset, bottom: 0, right: 0) + : UIEdgeInsets(top: 0, left: 0, bottom: 0, right: inset) } ancestor = view.superview } - return 0 + return .zero } - private func verticalBarIsOnTheRight() -> Bool { + private enum Side { case left, right } + + private func verticalBarSide() -> Side? { // The vertical-bar API ships in the iOS 27.1 SDK (UIKit 9127.0.85); see T3LayoutMetricsView. #if canImport(UIKit, _version: 9127.0.85) if #available(iOS 27.1, *) { let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft switch traitCollection.verticalBarEdge { - case .leading: return rtl - case .trailing: return !rtl - default: return false + case .leading: return rtl ? .right : .left + case .trailing: return rtl ? .left : .right + default: return nil } } #endif - return false + return nil } - /// Widening to the right leaves the content where React Native laid it out, so only the frame, - /// the container's clipping and the scroll indicator change; contentInset and contentOffset are - /// never touched. Nothing at all is written unless this view is bleeding or undoing its bleed. + /// Widening to the left would move the rows with it. Instead of a horizontal content offset, + /// which React Native rewrites on its own layout passes, the content view's bounds origin shifts + /// them back: React Native only ever sets that view's frame, which leaves its bounds origin. + /// Nothing at all is written unless this view is bleeding or undoing its bleed. private func apply() { guard let scrollView, let container else { return } - guard bleed > 0 || applied else { return } + let bleeding = bleed != .zero + guard bleeding || applied else { return } // React Native sizes the scroll view's component view; the UIScrollView inside follows it by // autoresizing, so the widened frame survives React Native's own layout updates. - let frame = container.bounds.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: 0, right: -bleed)) + let frame = container.bounds.inset( + by: UIEdgeInsets(top: 0, left: -bleed.left, bottom: 0, right: -bleed.right)) if scrollView.frame != frame { scrollView.frame = frame } - if bleed == 0 { + if let content, content.bounds.origin.x != -bleed.left { + content.bounds.origin.x = -bleed.left + } + if !bleeding { if let clipped = containerClipped { containerClipped = nil container.layer.masksToBounds = clipped @@ -115,19 +134,20 @@ final class T3ScrollEdgeBleedView: ExpoView { container.layer.masksToBounds = false } var indicators = scrollView.verticalScrollIndicatorInsets - if indicators.right != bleed { - indicators.right = bleed + if indicators.right != bleed.right { + indicators.right = bleed.right scrollView.verticalScrollIndicatorInsets = indicators } - applied = bleed > 0 + applied = bleeding } private func detach() { guard scrollView != nil else { return } - bleed = 0 + bleed = .zero apply() scrollView = nil container = nil + content = nil } /// The scroll view sits a fixed few levels down (marker, React Native's scroll view component); diff --git a/docs/internals/mobile-navigation.md b/docs/internals/mobile-navigation.md index cf4dea315940..e32f1da1c7eb 100644 --- a/docs/internals/mobile-navigation.md +++ b/docs/internals/mobile-navigation.md @@ -53,11 +53,12 @@ preserves behavior that is easy to break when changing native headers: columns pad their content away from the iPhone Duo's vertical bar. Register a header scroll view only through [`NativeScrollViewMarker`](../../apps/mobile/src/native/NativeScrollViewMarker.tsx) - or `NativeScrollView`: their native wrapper widens the scroll view to the right - under that bar, so the blur reaches it while the content keeps its React Native - frame. It never bleeds left: that would need a horizontal content offset, which - React Native rewrites on its own layout passes. Lint rejects `ScrollViewMarker` - imported anywhere else. + or `NativeScrollView`: their native wrapper widens the scroll view under that + bar, so the blur reaches it while the content stays in the safe area. On the + left it shifts React Native's content view back through its bounds origin, + never with a content offset: React Native rewrites offsets on its own layout + passes, which left threads shifted under the bar. Lint rejects + `ScrollViewMarker` imported anywhere else. The patch adds native header props that require code generation and a new binary. Patch rebuild guidance lives in the From c5332bf14dfb335c9eb08966624d65328e82641a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 11:03:14 -0700 Subject: [PATCH 002/536] fix(mobile): stabilize composer search debounce targets (#24272) --- .../src/state/queries.debounce.test.tsx | 105 ++++++++++++++++++ apps/mobile/src/state/queries.ts | 12 +- apps/mobile/vite.config.ts | 1 + 3 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/state/queries.debounce.test.tsx diff --git a/apps/mobile/src/state/queries.debounce.test.tsx b/apps/mobile/src/state/queries.debounce.test.tsx new file mode 100644 index 000000000000..b2ba738f0e10 --- /dev/null +++ b/apps/mobile/src/state/queries.debounce.test.tsx @@ -0,0 +1,105 @@ +import { AuthFilesystemReadScope, EnvironmentId } from "@t3tools/contracts"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + sessionAtom: {}, + searchEntries: vi.fn(() => null), +})); + +vi.mock("./session", () => ({ + environmentSession: { sessionStateAtom: () => mocks.sessionAtom }, +})); +vi.mock("./presentation", () => ({ + useEnvironmentPresentation: () => ({ + isReady: true, + presentation: { connection: { phase: "connected", error: null } }, + }), +})); +vi.mock("./pull-requests", () => ({ composerPullRequests: {} })); +vi.mock("./projects", () => ({ + projectEnvironment: { searchEntries: mocks.searchEntries }, +})); +vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); +vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} })); +vi.mock("./vcs", () => ({ vcsEnvironment: {} })); +vi.mock("./query", () => ({ + useEnvironmentQuery: (atom: unknown) => ({ + data: + atom === mocks.sessionAtom + ? { authenticated: true, scopes: [AuthFilesystemReadScope] } + : null, + error: null, + isPending: false, + refresh: () => {}, + }), +})); + +import { useComposerPathSearch, type ComposerPathSearchTarget } from "./queries"; + +function setup() { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers(); + mocks.searchEntries.mockClear(); + const root = createRoot(document.createElement("div")); + function Probe({ target }: { readonly target: ComposerPathSearchTarget }) { + useComposerPathSearch(target); + return null; + } + + return { + render(target: ComposerPathSearchTarget) { + act(() => root.render()); + }, + [Symbol.dispose]() { + act(() => root.unmount()); + vi.useRealTimers(); + }, + }; +} + +const target = { + environmentId: EnvironmentId.make("test-environment"), + cwd: "/repo", + query: "src", +}; + +it("keeps the original search deadline when rerenders have equivalent targets", () => { + using harness = setup(); + harness.render({ ...target, query: null }); + harness.render({ ...target, query: " src " }); + act(() => vi.advanceTimersByTime(100)); + harness.render(target); + act(() => vi.advanceTimersByTime(99)); + expect(mocks.searchEntries).not.toHaveBeenCalled(); + + act(() => vi.advanceTimersByTime(1)); + expect(mocks.searchEntries).toHaveBeenCalledExactlyOnceWith({ + environmentId: target.environmentId, + input: { cwd: target.cwd, query: target.query, limit: 20 }, + }); +}); + +it.each([ + { field: "environment", changed: { ...target, environmentId: EnvironmentId.make("other") } }, + { field: "project directory", changed: { ...target, cwd: "/other" } }, + { field: "query", changed: { ...target, query: "test" } }, +])("restarts the search deadline when the $field changes", ({ changed }) => { + using harness = setup(); + harness.render({ ...target, query: null }); + harness.render(target); + act(() => vi.advanceTimersByTime(100)); + harness.render(changed); + + act(() => vi.advanceTimersByTime(100)); + expect(mocks.searchEntries).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(99)); + expect(mocks.searchEntries).not.toHaveBeenCalled(); + + act(() => vi.advanceTimersByTime(1)); + expect(mocks.searchEntries).toHaveBeenCalledExactlyOnceWith({ + environmentId: changed.environmentId, + input: { cwd: changed.cwd, query: changed.query, limit: 20 }, + }); +}); diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 1665bf726ca5..d1b828081b78 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -30,7 +30,7 @@ import * as Cause from "effect/Cause"; import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "./atom-registry"; import { orchestrationEnvironment } from "./orchestration"; @@ -370,11 +370,11 @@ export function usePaginatedBranches(target: VcsRefTarget) { } export function useComposerPathSearch(target: ComposerPathSearchTarget) { - const normalizedTarget = { - environmentId: target.environmentId, - cwd: target.cwd, - query: normalizeComposerPathSearchQuery(target.query), - }; + const query = normalizeComposerPathSearchQuery(target.query); + const normalizedTarget = useMemo( + () => ({ environmentId: target.environmentId, cwd: target.cwd, query }), + [target.environmentId, target.cwd, query], + ); const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); const fileAccessSession = useEnvironmentQuery( debouncedTarget.environmentId === null diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 4c371d2dbc42..6313768d0458 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -10,6 +10,7 @@ const reactTests = [ "src/native/NativeHeaderToolbar.test.tsx", "src/native/useRestingTopOffset.test.tsx", "src/native/useRetryingImageSource.test.tsx", + "src/state/queries.debounce.test.tsx", "src/state/use-selected-thread-git-actions.test.tsx", ]; From daa182b8160b4acb4c3d518f28c4c2528e63d3a7 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 25 Oct 2026 14:27:27 -0400 Subject: [PATCH 003/536] fix(server): surface why process group metrics are not exported (#23969) Signed-off-by: Yordis Prieto --- .../ProcessGroupMetrics.test.ts | 65 ++++++++++++++++++- .../resourceTelemetry/ProcessGroupMetrics.ts | 26 +++++++- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/apps/server/src/resourceTelemetry/ProcessGroupMetrics.test.ts b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.test.ts index 694803b64842..f10937c4ebd4 100644 --- a/apps/server/src/resourceTelemetry/ProcessGroupMetrics.test.ts +++ b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.test.ts @@ -1,7 +1,17 @@ import { assert, describe, it } from "@effect/vitest"; -import type { ResourceTelemetryProcess } from "@t3tools/contracts"; +import type { ResourceTelemetryProcess, ResourceTelemetrySnapshot } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as References from "effect/References"; +import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; -import { groupProcesses } from "./ProcessGroupMetrics.ts"; +import * as ServerConfig from "../config.ts"; +import * as ProcessGroupMetrics from "./ProcessGroupMetrics.ts"; +import * as ResourceTelemetry from "./ResourceTelemetry.ts"; + +const { groupProcesses } = ProcessGroupMetrics; const process = ( pid: number, @@ -57,3 +67,54 @@ describe("groupProcesses", () => { }); }); }); + +describe("ProcessGroupMetrics.layer", () => { + it.effect("logs the first failed sample and the recovery, not every repeat", () => { + const logs: Array = []; + const logger = Logger.make(({ logLevel, message }) => { + logs.push(`${logLevel.toUpperCase()} ${String([message].flat()[0])}`); + }); + return Effect.gen(function* () { + const outcomes = yield* Ref.make>(["fail", "fail", "ok"]); + const refresh = Ref.modify(outcomes, ([next = "ok", ...rest]) => [next, rest] as const).pipe( + Effect.flatMap((next) => + next === "fail" + ? Effect.fail( + new ResourceTelemetry.ResourceTelemetryRefreshFailed({ + operation: "refresh", + cause: new Error("Resource monitor binary was not found for darwin/arm64."), + }), + ) + : Effect.succeed({ processes: [] } as unknown as ResourceTelemetrySnapshot), + ), + ); + const telemetry = { refresh } as unknown as ResourceTelemetry.ResourceTelemetry["Service"]; + + yield* Layer.build( + ProcessGroupMetrics.layer.pipe( + Layer.provide(Layer.succeed(ResourceTelemetry.ResourceTelemetry, telemetry)), + Layer.provide( + ServerConfig.layer({ + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + } as ServerConfig.ServerConfig["Service"]), + ), + ), + ); + yield* TestClock.adjust("3 minutes"); + + assert.deepStrictEqual(logs, [ + "WARN process group metrics are not exported: sample failed", + "DEBUG process group metrics sample failed", + "INFO process group metrics sample recovered", + ]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + Logger.layer([logger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Debug"), + ), + ), + ); + }); +}); diff --git a/apps/server/src/resourceTelemetry/ProcessGroupMetrics.ts b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.ts index 6f8f29c37161..b3a02e94e244 100644 --- a/apps/server/src/resourceTelemetry/ProcessGroupMetrics.ts +++ b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.ts @@ -1,7 +1,9 @@ import type { ResourceTelemetryProcess } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; +import * as Ref from "effect/Ref"; import * as ServerConfig from "../config.ts"; import { metricAttributes } from "../observability/Metrics.ts"; @@ -113,10 +115,28 @@ export const layer = Layer.effectDiscard( yield* Metric.update(Metric.withAttributes(processCount, attributes), total.count); } }); - yield* sample().pipe( - Effect.catch((error) => - Effect.logDebug("process group metrics sample failed", { error: error.message }), + // A failing collector would otherwise leave these series silently absent, so the first + // failure and the recovery are logged; the repeats stay at debug. + const failing = yield* Ref.make(false); + const recordSuccess = Ref.getAndSet(failing, false).pipe( + Effect.flatMap((wasFailing) => + wasFailing ? Effect.logInfo("process group metrics sample recovered") : Effect.void, ), + ); + const recordFailure = (error: ResourceTelemetry.ResourceTelemetryRefreshFailed) => { + const cause = Cause.fail(error); + return Ref.getAndSet(failing, true).pipe( + Effect.flatMap((wasFailing) => + wasFailing + ? Effect.logDebug("process group metrics sample failed", cause) + : Effect.logWarning("process group metrics are not exported: sample failed", cause), + ), + Effect.annotateLogs({ operation: error.operation }), + ); + }; + yield* sample().pipe( + Effect.andThen(recordSuccess), + Effect.catch(recordFailure), Effect.andThen(Effect.sleep(SAMPLE_INTERVAL)), Effect.forever, Effect.forkScoped, From 3ed489081ba023a4ef9293a0f2123120b841ee7d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 11:31:17 -0700 Subject: [PATCH 004/536] test(mobile): mock threads in the composer search debounce test (#24954) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/state/queries.debounce.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/state/queries.debounce.test.tsx b/apps/mobile/src/state/queries.debounce.test.tsx index b2ba738f0e10..0a4c8ff828f3 100644 --- a/apps/mobile/src/state/queries.debounce.test.tsx +++ b/apps/mobile/src/state/queries.debounce.test.tsx @@ -24,6 +24,7 @@ vi.mock("./projects", () => ({ vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} })); vi.mock("./vcs", () => ({ vcsEnvironment: {} })); +vi.mock("./threads", () => ({ olderSettledThreads: {}, threadFamilies: {} })); vi.mock("./query", () => ({ useEnvironmentQuery: (atom: unknown) => ({ data: From 133b9b0c72ce859e4c3ac25aec201d22d82a94ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 11:31:25 -0700 Subject: [PATCH 005/536] fix(server): serve audio and video from draft workspaces (#24275) --- apps/server/src/assets/AssetAccess.test.ts | 173 +++++++++++++++++++++ apps/server/src/assets/AssetAccess.ts | 25 ++- 2 files changed, 196 insertions(+), 2 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index d4db872fd2f8..d3eb0e051544 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -271,6 +271,179 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("previews draft audio and video from relative workspace paths", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-draft-media-" }); + for (const [name, mimeType] of [ + ["video.mp4", "video/mp4"], + ["audio.wav", "audio/wav"], + ] as const) { + const filePath = path.join(root, name); + yield* fs.writeFileString(filePath, "media bytes"); + const result = yield* issueAssetUrl({ + resource: { _tag: "draft-workspace-file", cwd: root, path: name }, + workspaceRoot: root, + }); + expect(result.fileRevision).toEqual(expect.any(String)); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + const asset = yield* resolveAsset(token, suffix.slice(separator + 1)); + expect(asset).toMatchObject({ kind: "file", path: yield* fs.realPath(filePath), mimeType }); + if (asset?.kind !== "file") throw new Error("Expected draft media file"); + for (const [range, status, body] of [ + [undefined, 200, "media bytes"], + ["bytes=2-5", 206, "dia "], + ] as const) { + const resolved = yield* resolveAsset(token, suffix.slice(separator + 1)); + if (resolved?.kind !== "file") throw new Error("Expected draft media file"); + const response = HttpServerResponse.toWeb(yield* assetFileResponse(resolved, range)); + expect(response.status).toBe(status); + expect(response.headers.get("content-type")).toBe(mimeType); + expect(response.headers.get("content-length")).toBe(String(body.length)); + if (range) expect(response.headers.get("content-range")).toBe("bytes 2-5/11"); + expect(yield* Effect.promise(() => response.text())).toBe(body); + } + for (const requestedPath of [ + name === "video.mp4" ? "audio.wav" : "video.mp4", + `../${name}`, + ]) { + expect(yield* resolveAsset(token, requestedPath)).toBeNull(); + } + expect(yield* resolveAsset(`${token}tampered`, name)).toBeNull(); + } + }).pipe(Effect.provide(layerTest)), + ); + + it.effect( + "rejects draft media escapes and disallowed types without enabling non-draft media", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-draft-boundary-" }); + const root = path.join(directory, "workspace"); + yield* fs.makeDirectory(root); + for (const name of ["video.mp4", "audio.wav", "secret.txt"]) { + yield* fs.writeFileString(path.join(root, name), "workspace bytes"); + yield* fs.writeFileString(path.join(directory, name), "outside bytes"); + } + for (const [requestedPath, tag] of [ + ["../video.mp4", "AssetWorkspacePathValidationError"], + ["../audio.wav", "AssetWorkspacePathValidationError"], + ["secret.txt", "AssetPreviewTypeValidationError"], + ] as const) { + const error = yield* issueAssetUrl({ + resource: { _tag: "draft-workspace-file", cwd: root, path: requestedPath }, + workspaceRoot: root, + }).pipe(Effect.flip); + expect(error._tag).toBe(tag); + } + for (const name of ["video.mp4", "audio.wav"]) { + const error = yield* issueAssetUrl({ + resource: { _tag: "workspace-file", threadId: ThreadId.make("thread-1"), path: name }, + workspaceRoot: root, + }).pipe(Effect.flip); + expect(error._tag).toBe("AssetPreviewTypeValidationError"); + } + }).pipe(Effect.provide(layerTest)), + ); + + it.effect.skipIf(!symlinksSupported)( + "rejects draft media symlinks escaping the workspace or targeting disallowed types", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-draft-symlink-" }); + const root = path.join(directory, "workspace"); + yield* fs.makeDirectory(root); + const outside = path.join(directory, "outside.mp4"); + const secret = path.join(root, "secret.txt"); + yield* fs.writeFileString(outside, "outside bytes"); + yield* fs.writeFileString(secret, "private bytes"); + for (const [name, target] of [ + ["escape.mp4", outside], + ["disguised.wav", secret], + ] as const) { + yield* fs.symlink(target, path.join(root, name)); + const error = yield* issueAssetUrl({ + resource: { _tag: "draft-workspace-file", cwd: root, path: name }, + workspaceRoot: root, + }).pipe(Effect.flip); + expect(["AssetWorkspaceAssetNotFoundError", "AssetPreviewTypeValidationError"]).toContain( + error._tag, + ); + } + }).pipe(Effect.provide(layerTest)), + ); + + it.effect.skipIf(!symlinksSupported)( + "preserves absolute draft previews through stable symlinks", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-draft-absolute-" }); + for (const extension of ["png", "mp4", "wav"]) { + const target = path.join(root, `target.${extension}`); + const alias = path.join(root, `alias.${extension}`); + yield* fs.writeFileString(target, "preview bytes"); + yield* fs.symlink(target, alias); + const result = yield* issueAssetUrl({ + resource: { _tag: "draft-workspace-file", cwd: root, path: alias }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)), + ).toMatchObject({ kind: "file", path: yield* fs.realPath(target) }); + } + }).pipe(Effect.provide(layerTest)), + ); + + it.effect.skipIf(!symlinksSupported)( + "rejects a draft media path swapped after workspace containment validation", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-draft-swap-" }); + const root = path.join(directory, "workspace"); + yield* fs.makeDirectory(root); + const filePath = path.join(root, "video.mp4"); + const outside = path.join(directory, "outside.mp4"); + yield* fs.writeFileString(filePath, "workspace video"); + yield* fs.writeFileString(outside, "outside video"); + const canonicalFile = yield* fs.realPath(filePath); + let swapped = false; + const swappingFileSystem = FileSystem.FileSystem.of({ + ...fs, + stat: Effect.fn(function* (requestedPath) { + const info = yield* fs.stat(requestedPath); + if (requestedPath === canonicalFile && !swapped) { + swapped = true; + yield* fs.remove(filePath); + yield* fs.symlink(outside, filePath); + } + return info; + }), + }); + const result = yield* issueAssetUrl({ + resource: { _tag: "draft-workspace-file", cwd: root, path: "video.mp4" }, + workspaceRoot: root, + }).pipe(Effect.result, Effect.provideService(FileSystem.FileSystem, swappingFileSystem)); + expect(swapped).toBe(true); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "AssetWorkspaceAssetNotFoundError" }, + }); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("resolves home-relative media paths independently of the workspace", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index cd30e0b16c9d..242562baf801 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -21,8 +21,10 @@ import { import { audioMimeTypeFromExtension, hostPreviewMimeTypeFromExtension, + isWorkspaceAudioPreviewPath, isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, + isWorkspaceVideoPreviewPath, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; @@ -346,6 +348,7 @@ const readImageDimensionsFromHeader = (filePath: string) => const finalizeAbsoluteMediaFileAsset = Effect.fn("AssetAccess.finalizeAbsoluteMediaFileAsset")( function* (input: { readonly requestedPath: string; + readonly expectedCanonicalPath?: string; readonly workspaceRoot: string | undefined; readonly resource: AssetResource; readonly expiresAt: number; @@ -371,7 +374,12 @@ const finalizeAbsoluteMediaFileAsset = Effect.fn("AssetAccess.finalizeAbsoluteMe (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), ), ); - if (!canonicalFile) { + // The workspace caller pins its containment-checked path; never follow a + // replacement symlink to a different file during this second resolution. + if ( + !canonicalFile || + (input.expectedCanonicalPath !== undefined && canonicalFile !== input.expectedCanonicalPath) + ) { return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource, checkedPath: input.requestedPath, @@ -446,7 +454,11 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA }), ), ); - if (!isWorkspacePreviewEntryPath(resolved.relativePath)) { + const isDraftMedia = + input.resource._tag === "draft-workspace-file" && + (isWorkspaceAudioPreviewPath(resolved.relativePath) || + isWorkspaceVideoPreviewPath(resolved.relativePath)); + if (!isDraftMedia && !isWorkspacePreviewEntryPath(resolved.relativePath)) { return yield* new AssetPreviewTypeValidationError({ resource: input.resource, }); @@ -468,6 +480,13 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA resource: input.resource, }); } + if (isDraftMedia) { + return yield* finalizeAbsoluteMediaFileAsset({ + ...input, + requestedPath: canonicalFile, + expectedCanonicalPath: canonicalFile, + }); + } const canonicalWorkspaceRoot = yield* fileSystem.realPath(input.workspaceRoot).pipe( Effect.mapError( (cause) => @@ -500,6 +519,7 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA }, fileName: path.basename(resolved.relativePath), imageDimensions, + fileRevision: undefined, }; }, ); @@ -612,6 +632,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i claims = finalized.claims; fileName = finalized.fileName; imageDimensions = finalized.imageDimensions; + fileRevision = finalized.fileRevision; break; } case "attachment": { From 8096799b85bdfd97ce75ad921a88e7f47ddbbf9f Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 25 Oct 2026 11:33:07 -0700 Subject: [PATCH 006/536] fix(mobile): share toast motion and bring bottom undo to iOS (#23968) --- .../mobile/src/components/ui/GlassSurface.tsx | 5 +- apps/mobile/src/components/ui/Toast.tsx | 182 ++++++++++++++++-- .../development/ComponentCatalogScreen.tsx | 41 ++-- .../home/ThreadUndoNotice.android.tsx | 128 ------------ .../src/features/home/ThreadUndoNotice.tsx | 75 +++++++- .../threads/GitActionProgressOverlay.tsx | 20 +- 6 files changed, 278 insertions(+), 173 deletions(-) delete mode 100644 apps/mobile/src/features/home/ThreadUndoNotice.android.tsx diff --git a/apps/mobile/src/components/ui/GlassSurface.tsx b/apps/mobile/src/components/ui/GlassSurface.tsx index 05bf0532e460..8af080fc19cc 100644 --- a/apps/mobile/src/components/ui/GlassSurface.tsx +++ b/apps/mobile/src/components/ui/GlassSurface.tsx @@ -25,7 +25,8 @@ interface GlassSurfaceProps extends ViewProps { readonly ref?: Ref; readonly children: ReactNode; readonly glassEffectStyle?: "clear" | "regular" | "none"; - readonly tintColor?: ColorValue; + // null leaves UIKit's material untinted instead of applying the theme tint. + readonly tintColor?: ColorValue | null; readonly tintColorClassName?: string; readonly chrome?: "default" | "none"; readonly fallbackColor?: ColorValue; @@ -71,7 +72,7 @@ export function GlassSurface({ className, )} glassEffectStyle={glassEffectStyle} - tintColor={tintColor === undefined ? undefined : String(tintColor)} + tintColor={tintColor == null ? undefined : String(tintColor)} tintColorClassName={ tintColorClassName ?? (tintColor === undefined ? "accent-glass-tint" : undefined) } diff --git a/apps/mobile/src/components/ui/Toast.tsx b/apps/mobile/src/components/ui/Toast.tsx index 2daa0873b65f..96039971d1fa 100644 --- a/apps/mobile/src/components/ui/Toast.tsx +++ b/apps/mobile/src/components/ui/Toast.tsx @@ -1,36 +1,71 @@ -import { MOBILE_SHAPE } from "../../lib/designTokens"; -import { useEffect } from "react"; -import { AccessibilityInfo, Platform, View, useWindowDimensions } from "react-native"; +import { MOBILE_MOTION, MOBILE_SHAPE } from "../../lib/designTokens"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; +import { AccessibilityInfo, Platform, StyleSheet, View, useWindowDimensions } from "react-native"; +import { GestureDetector, usePanGesture } from "react-native-gesture-handler"; +import Animated, { + cancelAnimation, + Easing, + FadeOut, + ReduceMotion, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, + type EntryAnimationsValues, +} from "react-native-reanimated"; import { AppText } from "./AppText"; import { Button } from "./Button"; import { GlassSurface } from "./GlassSurface"; +import { useControlTheme } from "./useControlTheme"; import { IconButton } from "./IconButton"; import { AppActivityIndicator } from "./AppActivityIndicator"; import { SymbolView } from "./AppSymbol"; -export interface ToastProps { +export type ToastProps = { readonly message: string; readonly description?: string; + readonly singleLine?: boolean; readonly tone?: "default" | "success" | "danger"; readonly busy?: boolean; readonly action?: { readonly label: string; readonly onPress: () => void }; - readonly onDismiss?: () => void; -} +} & ( + | { readonly dismissMode: "swipe"; readonly onDismiss: () => void } + | { readonly dismissMode?: "button"; readonly onDismiss?: () => void } +); export function Toast(props: ToastProps) { + return props.dismissMode === "swipe" ? ( + + ) : ( + + ); +} + +function ToastContent(props: ToastProps & { readonly glassReady?: boolean }) { const { fontScale } = useWindowDimensions(); - // A one-line message keeps its action on the same row. Descriptions and - // accessibility text sizes need the full width, so the action moves below. - const inlineAction = !props.description && fontScale < 1.5; + const { colorScheme } = useControlTheme(); + const borderRadius = Platform.OS === "ios" ? 31 : MOBILE_SHAPE.toast; + // Explicit single-line notices reserve the action's width and truncate the text. + const inlineAction = props.singleLine || (!props.description && fontScale < 1.5); + const message = + props.singleLine && props.description + ? `${props.message}: ${props.description}` + : props.message; const action = props.action ? ( + ) : ( + middleTruncateAttachmentName(props.name) + )} + + + {props.failed + ? "Unavailable" + : loading + ? "..." + : duration > 0 + ? elapsed > 0 || playing + ? `${audioTime(elapsed)} / ${audioTime(duration)}` + : audioTime(duration) + : audioTime(elapsed)} + + {props.src && !props.failed ? ( +