Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6ab39ff
test(web): require-disposable-render is now an error (#22410)
juliusmarminge Oct 20, 2026
f7f9e21
test(web): migrate the last .ts browser tests and stop wrapping userE…
juliusmarminge Oct 20, 2026
5d4c202
fix(web,mobile): Limits bars show the even-pace mark as a tick in a g…
juliusmarminge Oct 20, 2026
7e289ae
test(web): pooled Limits segments narrower than 6px draw no pace mark…
juliusmarminge Oct 20, 2026
dac3437
fix(mobile): the composer stays one row until the draft wraps (#20812)
juliusmarminge Oct 20, 2026
860e6d2
fix(web): restore left-aligned markdown table cells (#22482)
t3-code[bot] Oct 20, 2026
0c85b0d
feat(scheduled-tasks): event filters for webhook deliveries (#22194)
juliusmarminge Oct 20, 2026
ec58572
fix(mobile): prevent update checker from crashing iOS at launch (#22488)
juliusmarminge Oct 20, 2026
2ebe280
feat(scheduled-tasks): replay dedupe and coalescing for webhook deliv…
juliusmarminge Oct 20, 2026
14b47ee
ci(release): Make Windows arm64 CI job 50% faster (#22428)
jahands Oct 20, 2026
4c48118
ci(release): Windows arm64 verify no longer runs the crashing ARM Pow…
t3dotgg Oct 20, 2026
ca140c4
refactor(source-control): GitCafe creates repositories and pull reque…
juliusmarminge Oct 20, 2026
5bcc90e
test(web): fix release browser test failures (#22508)
t3dotgg Oct 20, 2026
c8667fd
fix(mobile): connecting an environment no longer crashes the header (…
juliusmarminge Oct 20, 2026
4af3231
fix(mobile): folded Back clears the detail route before the next sele…
juliusmarminge Oct 20, 2026
fe27e8e
ci(release): release tests stop timing out and finish in about 5 minu…
t3dotgg Oct 20, 2026
5d27ecd
test(server): Claude steer test no longer hangs for 120s under CI loa…
t3dotgg Oct 20, 2026
f9dd259
test: remove the timing races behind recurring CI and release failure…
t3dotgg Oct 20, 2026
fcb20e4
feat(web): render Mermaid diagrams with pele (#21472)
maria-rcks Oct 20, 2026
1c887e4
Merge remote-tracking branch 'upstream/main' into yordis/chore-sync-u…
yordis Oct 20, 2026
c38b488
test(web): timeline browser test warms up pele instead of the removed…
yordis Oct 20, 2026
fec67a8
ci: pin the shared dependency cache action to a commit
yordis Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/setup-ci-deps/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ runs:
# package across filesystems instead of hard-linking it: install took 28-49s
# against 9s plus an 11s restore.
- name: Cache full pnpm store
uses: actions/cache@v6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm_store.outputs.path }}
key: pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ jobs:

# The web suite is the largest outside the server. In the shared Test job it
# started last and ran alone for about 40s after everything else finished.
# release.yml mirrors this test, test_web, and test_server split.
test_web:
name: Test Web
needs: changes
Expand Down
420 changes: 272 additions & 148 deletions .github/workflows/release-desktop.yml

Large diffs are not rendered by default.

62 changes: 55 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ jobs:
run: vp run typecheck

# Keep tests on their own runners, using the same package split and server
# shards as CI, so the release check job does not spend its budget on tests.
# shards as CI (test, test_web, test_server in ci.yml). Keep the two in sync.
test:
name: Release tests
needs: [preflight]
Expand Down Expand Up @@ -286,13 +286,57 @@ jobs:
sudo sed -i 's|http://|https://|g' /etc/apt/ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config build-essential

# The web package's browser project runs React tests in headless Chromium.
- name: Test
run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web' test

# Web browser tests time out when they share a runner with the other package
# suites: Chromium gets too little CPU and diff workers miss their 10s start budget.
test_web:
name: Release web tests
needs: [preflight]
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ needs.preflight.outputs.ref }}
sparse-checkout: |
/*
!/.repos/
sparse-checkout-cone-mode: false

- name: Setup dependencies
uses: ./.github/actions/setup-ci-deps

- name: Resolve Playwright version
id: playwright_version
run: echo "version=$(node -p "require('./apps/web/node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT"

- name: Cache Chromium
id: chromium_cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }}

- name: Install Chromium for web browser tests
run: node apps/web/node_modules/playwright/cli.js install --with-deps --only-shell chromium
if: steps.chromium_cache.outputs.cache-hit != 'true'
run: node apps/web/node_modules/playwright/cli.js install --only-shell chromium

- name: Install missing Chromium system dependencies
run: |
browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit)
test -n "$browser"
if ldd "$browser" | grep '=> not found'; then
node apps/web/node_modules/playwright/cli.js install-deps chromium
fi

- name: Test
run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test
run: vp run --filter @t3tools/web test

# Server test files run one at a time, so more small runners beat fewer big ones.
test_server:
name: Release server tests ${{ matrix.shard }}
needs: [preflight]
Expand All @@ -302,7 +346,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
shard: [1, 2, 3, 4, 5, 6, 7, 8]
steps:
- name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down Expand Up @@ -675,6 +719,8 @@ jobs:
resource_key: win32-x64
cli_archive: true

# Windows arm64 runners have 4 vCPUs, and the packaging is mostly CPU-bound,
# so the build runs on Linux and only the native checks run on arm64.
desktop_win_arm64:
name: Desktop Windows arm64
needs: [preflight, relay_public_config, build_bundle]
Expand All @@ -690,7 +736,8 @@ jobs:
clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}
relay_url: ${{ needs.relay_public_config.outputs.relay_url }}
label: Windows arm64
runner: windows-11-arm
runner: ubuntu-24.04
verify_runner: windows-11-arm
platform: win
target: nsis
arch: arm64
Expand All @@ -706,6 +753,7 @@ jobs:
relay_public_config,
quality,
test,
test_web,
test_server,
desktop_mac_arm64,
desktop_mac_x64,
Expand All @@ -714,7 +762,7 @@ jobs:
desktop_win_x64,
desktop_win_arm64,
]
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.outputs.relay_url != '' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }}
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.outputs.relay_url != '' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_web.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/windows-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
sparse-checkout: |
/*
Expand All @@ -40,7 +40,7 @@ jobs:
# setup-vp's own cache restores a Linux-shaped store on Windows, which is
# slower than no cache (see #7975). Cache pnpm's Windows store directly.
- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0
with:
node-version-file: package.json
cache: false
Expand All @@ -52,7 +52,7 @@ jobs:
run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT'

- name: Cache packages
uses: actions/cache@v6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.package_cache_path.outputs.path }}
key: windows-tests-packages-v1-${{ hashFiles('pnpm-lock.yaml') }}
Expand Down
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,12 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real
- **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite.
- Backend behavior changes ship with focused tests for that behavior.
- The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong.
- Web component tests are `*.browser.test.tsx` and render through `apps/web/test/render.tsx`. Acquire everything a test changes with `using`/`await using` so disposal undoes it even when the test fails; no `beforeEach`/`afterEach`, no module-level screens. Shared mock state that `vi.mock` factories read gets reset by a `setup()` call at the top of the test. `t3code/require-disposable-render` flags the old patterns.
- Web browser tests are `*.browser.test.tsx` or `*.browser.test.ts` and render with `await using screen = await render(...)` from `apps/web/test/render.tsx`. `t3code/require-disposable-render` errors on the old patterns.
- Acquire what the test drives after mounting (fake timers it advances, spies it sets up after mounting) after the render, so it disposes before the unmount. Anything the first render reads comes before it.
- A helper that renders and then waits or clicks puts the screen in an `AsyncDisposableStack` and `move()`s it out only on success, so a failed wait still unmounts.
- `rerender` needs a fresh element on every call.
- No lifecycle hooks and no module-level mutable state. Shared mock state that `vi.mock` factories read is reset by a `setup()` call at the top of the test.
- `router` is on by default. Pass `router: false` only with a one-line comment saying why.

```tsx
it("saves on Enter", async () => {
Expand Down
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
private var applyingNativeValue = false
private var desiredLineHeightPx = 0
private var lastContentHeight = 0
private var lastContentWidth = 0
private var contentInsetVertical = 0
private var tokensJson = "[]"
private var tokens: List<ComposerToken> = emptyList()
Expand Down Expand Up @@ -424,12 +425,19 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
}

private fun emitContentSizeIfNeeded() {
val height = editor.layout?.height ?: editor.measuredHeight
val contentHeight = height + contentInsetVertical * 2
if (contentHeight == lastContentHeight) return
// The laid-out text without insets. A one-line draft reports its natural width, so
// JS can tell whether it fits a narrower row than the one it is laid out in; past
// one line only the height matters. Trailing whitespace hangs past a line without
// wrapping it, so getLineMax leaves it out.
val layout = editor.layout
val contentHeight = layout?.height ?: editor.measuredHeight
val contentWidth = if (layout?.lineCount == 1) layout.getLineMax(0).toInt() else 0
if (contentHeight == lastContentHeight && contentWidth == lastContentWidth) return
lastContentHeight = contentHeight
lastContentWidth = contentWidth
val density = resources.displayMetrics.density
onComposerContentSizeChange(
mapOf("height" to contentHeight / resources.displayMetrics.density),
mapOf("width" to contentWidth / density, "height" to contentHeight / density),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1230,7 +1230,28 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
}

private func emitContentSizeIfNeeded() {
let nextSize = textView.contentSize
// The laid-out text without insets. A one-line draft reports its natural width, so
// JS can tell whether it fits a narrower row than the one it is laid out in; past
// one line only the height matters, so layout stops at the second line. The width
// comes from the line fragment because usedRect spans the container once the view
// scrolls. A trailing newline's empty line only exists as the extra fragment.
let layout = textView.layoutManager
let container = textView.textContainer
var lines = 0
var width: CGFloat = 0
var height: CGFloat = 0
let glyphs = NSRange(location: 0, length: layout.numberOfGlyphs)
layout.enumerateLineFragments(forGlyphRange: glyphs) { _, used, _, _, stop in
lines += 1
width = used.width
height = used.maxY
if lines > 1 { stop.pointee = true }
}
if lines < 2, layout.extraLineFragmentTextContainer === container {
lines += 1
height = max(height, layout.extraLineFragmentUsedRect.maxY)
}
let nextSize = CGSize(width: lines == 1 ? ceil(width) : 0, height: ceil(height))
guard abs(nextSize.width - lastContentSize.width) > 0.5 ||
abs(nextSize.height - lastContentSize.height) > 0.5 else {
return
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@
"react-native-pdf-jsi": "5.0.0",
"react-native-reanimated": "4.7.1",
"react-native-safe-area-context": "~5.10.1",
"react-native-screens": "file:deps/react-native-screens-5.0.0-t3.29.tgz",
"react-native-screens": "file:deps/react-native-screens-5.0.0-t3.30.tgz",
"react-native-shiki-engine": "^0.3.12",
"react-native-svg": "15.16.0",
"react-native-webview": "14.0.1",
Expand Down
123 changes: 76 additions & 47 deletions apps/mobile/src/components/ComposerToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
type ViewStyle,
} from "react-native";

import Animated, { FadeIn, LinearTransition } from "react-native-reanimated";

import { cn } from "../lib/cn";
import { AppText as Text } from "./ui/AppText";
import { SymbolView } from "./ui/AppSymbol";
Expand All @@ -21,6 +23,10 @@ import { useAndroidControlSizing } from "./ui/useAndroidControlSizing";
const COMPOSER_TOOLBAR_GAP = 8;
const COMPOSER_TOOLBAR_FADE_WIDTH = 18;
const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4;
const INLINE_CONTROL_LABEL_ENTERING = FadeIn.duration(160);
// Android runs no composer layout transitions, so the toolbar never trails the dock.
const INLINE_CONTROL_WIDTH_TRANSITION =
Platform.OS === "android" ? undefined : LinearTransition.duration(180);

export function ComposerInlineControl(props: {
readonly accessibilityHint?: string;
Expand All @@ -30,6 +36,8 @@ export function ComposerInlineControl(props: {
readonly icon?: ComponentProps<typeof SymbolView>["name"];
readonly renderIcon?: (size: number) => ReactNode;
readonly label: string;
/** Show only the icon and chevron; the label still names the control for accessibility. */
readonly iconOnly?: boolean;
readonly maxWidth?: ViewStyle["maxWidth"];
readonly onPress?: () => void;
readonly selected?: boolean;
Expand All @@ -38,56 +46,77 @@ export function ComposerInlineControl(props: {
readonly showChevron?: boolean;
}) {
const { scale, smallIconSize } = useAndroidControlSizing();
// The label fades in as the control widens, and leaves at once: an exiting view keeps
// drawing at its old place while the chevron moves into it. The clip keeps content laid out
// at the final width inside the frame while it eases there.
const animatesLabel = props.iconOnly !== undefined;
return (
<Pressable
accessibilityLabel={props.accessibilityLabel ?? props.label}
accessibilityHint={props.accessibilityHint}
accessibilityRole={props.static ? undefined : "button"}
accessibilityState={
props.static ? undefined : { disabled: props.disabled, selected: props.selected }
}
className="h-11 flex-row items-center gap-2 rounded-xl px-2 active:bg-subtle"
disabled={props.disabled || props.static}
onPress={props.onPress}
style={{ maxWidth: props.maxWidth ?? 190, opacity: props.disabled ? 0.45 : 1 }}
<Animated.View
layout={animatesLabel ? INLINE_CONTROL_WIDTH_TRANSITION : undefined}
style={{
maxWidth: props.maxWidth ?? 190,
overflow: animatesLabel ? "hidden" : undefined,
}}
>
{props.renderIcon ? (
<View
className="size-4 shrink-0 items-center justify-center"
style={Platform.OS === "android" ? { width: 14 * scale, height: 14 * scale } : undefined}
>
{props.renderIcon(smallIconSize)}
</View>
) : props.icon ? (
<SymbolView
name={props.icon}
size={smallIconSize}
tintColorClassName={
props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
}
type="monochrome"
/>
) : null}
<Text
className={cn(
"shrink text-sm font-t3-medium",
props.emphasized || props.selected ? "text-foreground" : "text-foreground-muted",
)}
numberOfLines={1}
<Pressable
accessibilityLabel={props.accessibilityLabel ?? props.label}
accessibilityHint={props.accessibilityHint}
accessibilityRole={props.static ? undefined : "button"}
accessibilityState={
props.static ? undefined : { disabled: props.disabled, selected: props.selected }
}
className="h-11 flex-row items-center gap-2 rounded-xl px-2 active:bg-subtle"
disabled={props.disabled || props.static}
onPress={props.onPress}
style={{ opacity: props.disabled ? 0.45 : 1 }}
>
{props.label}
</Text>
{props.showChevron === false ? null : (
<SymbolView
name={props.chevronDirection === "right" ? "chevron.right" : "chevron.down"}
size={Math.round(10 * scale)}
tintColorClassName={
props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
}
type="monochrome"
/>
)}
</Pressable>
{props.renderIcon ? (
<View
className="size-4 shrink-0 items-center justify-center"
style={
Platform.OS === "android" ? { width: 14 * scale, height: 14 * scale } : undefined
}
>
{props.renderIcon(smallIconSize)}
</View>
) : props.icon ? (
<SymbolView
name={props.icon}
size={smallIconSize}
tintColorClassName={
props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
}
type="monochrome"
/>
) : null}
{props.iconOnly ? null : (
<Animated.View
className="shrink"
entering={animatesLabel ? INLINE_CONTROL_LABEL_ENTERING : undefined}
>
<Text
className={cn(
"text-sm font-t3-medium",
props.emphasized || props.selected ? "text-foreground" : "text-foreground-muted",
)}
numberOfLines={1}
>
{props.label}
</Text>
</Animated.View>
)}
{props.showChevron === false ? null : (
<SymbolView
name={props.chevronDirection === "right" ? "chevron.right" : "chevron.down"}
size={Math.round(10 * scale)}
tintColorClassName={
props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
}
type="monochrome"
/>
)}
</Pressable>
</Animated.View>
);
}

Expand Down
Loading
Loading