Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -748,8 +748,9 @@ jobs:
name: Publish GitHub Release
needs: [preflight, build, publish_cli]
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }}
# Blacksmith runners are not available to this fork.
runs-on: ubuntu-24.04
timeout-minutes: 10
timeout-minutes: 30
permissions:
contents: write
steps:
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ const config: ExpoConfig = {
"./plugins/withAndroidModernPopupMenu.cjs",
"./plugins/withAndroidModernAlertDialog.cjs",
"./plugins/withAndroidPredictiveBackCompat.cjs",
"./plugins/withAndroidTabletOrientation.cjs",
...(isIosPersonalTeamBuild ? ["./plugins/withoutIosPersonalTeamCapabilities.cjs"] : []),
],
extra: {
Expand Down
80 changes: 80 additions & 0 deletions apps/mobile/plugins/withAndroidTabletOrientation.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const { withMainActivity } = require("expo/config-plugins");

// The top-level `orientation: "portrait"` writes android:screenOrientation="portrait"
// into the manifest, which locks every Android device — including tablets — to
// portrait. iOS doesn't have this problem: iPads must support all orientations
// because the app is multitasking-capable, so only iPhones end up portrait-only.
// Mirror that split on Android: keep the manifest lock for phones and lift it at
// runtime on tablets (smallest width >= 600dp, the standard tablet breakpoint),
// since requestedOrientation set at runtime overrides the manifest value.
// FULL_USER allows all four orientations while still respecting the user's
// auto-rotate lock, matching iPad behavior. Foldables change
// smallestScreenWidthDp on fold/unfold without recreating the activity
// (smallestScreenSize is in the manifest's configChanges), so the policy is
// re-evaluated in onConfigurationChanged: unfolding past the tablet breakpoint
// unlocks rotation, and folding back restores the portrait lock.

const ORIENTATION_METHODS = `
// Applied in onCreate and re-applied on fold/unfold; added by
// withAndroidTabletOrientation.
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
applyTabletOrientation()
}

private fun applyTabletOrientation() {
requestedOrientation = if (resources.configuration.smallestScreenWidthDp >= 600) {
ActivityInfo.SCREEN_ORIENTATION_FULL_USER
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
`;

const ORIENTATION_ON_CREATE_CALL = `
applyTabletOrientation()`;

function insertAfter(contents, anchor, insertion, description) {
const index = contents.indexOf(anchor);
if (index === -1) {
throw new Error(
`withAndroidTabletOrientation: could not find ${description} in MainActivity — the Expo template changed; update the plugin anchors.`,
);
}
const end = index + anchor.length;
return contents.slice(0, end) + insertion + contents.slice(end);
}

module.exports = function withAndroidTabletOrientation(config) {
return withMainActivity(config, (nextConfig) => {
let contents = nextConfig.modResults.contents;
if (nextConfig.modResults.language !== "kt") {
throw new Error("withAndroidTabletOrientation: MainActivity must be Kotlin.");
}
if (contents.includes("SCREEN_ORIENTATION_FULL_USER")) {
return nextConfig;
}

contents = insertAfter(
contents,
"import android.os.Bundle",
"\nimport android.content.pm.ActivityInfo\nimport android.content.res.Configuration",
"the android.os.Bundle import",
);
contents = insertAfter(
contents,
"class MainActivity : ReactActivity() {",
ORIENTATION_METHODS,
"the MainActivity class declaration",
);
contents = insertAfter(
contents,
"super.onCreate(null)",
ORIENTATION_ON_CREATE_CALL,
"the super.onCreate call",
);

nextConfig.modResults.contents = contents;
return nextConfig;
});
};
12 changes: 10 additions & 2 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import type {
import * as Haptics from "expo-haptics";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Platform, StyleSheet, View, type GestureResponderEvent } from "react-native";
import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
import {
KeyboardController,
KeyboardStickyView,
useKeyboardState,
} from "react-native-keyboard-controller";
import Animated, { FadeInDown, FadeOut } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";

Expand Down Expand Up @@ -194,7 +198,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null);
const [composerExpanded, setComposerExpanded] = useState(false);
const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12);
// Key the safe-area padding on keyboard visibility, not focus: on Android
// the back gesture closes the keyboard while the editor stays focused, and
// a focus-keyed inset would leave the toolbar under the gesture bar.
const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
const composerBottomInset = isKeyboardVisible ? 0 : Math.max(insets.bottom, 12);
const contentPresentationKind = props.contentPresentation.kind;
// The raw sync status enters "synchronizing" on every full fetch, cached or
// not. Whether messages are already on screen decides the pill label: no
Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/provider/opencodeRuntime.cliParsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,31 @@ describe("parseModelsCliOutput", () => {
NodeAssert.ok(model.variants);
NodeAssert.equal(model.variants!["medium"] !== undefined, true);
});

it("keeps a model whose JSON body has a slash and no interior whitespace", () => {
// OpenRouter-style: the model id contains a `/` and no string value has a
// space, so the JSON body line itself matches the slug regex. It must still
// be treated as the body of the preceding slug, not a new slug.
const stdout = [
"openrouter/qwen/qwen3-coder",
JSON.stringify({
id: "qwen/qwen3-coder",
providerID: "openrouter",
name: "qwen3-coder",
status: "active",
}),
].join("\n");

const result = parseModelsCliOutput(stdout);
NodeAssert.equal(result.providers.size, 1);
NodeAssert.deepEqual([...result.connected], ["openrouter"]);
const provider = result.providers.get("openrouter")!;
NodeAssert.ok(provider);
const model = provider.models["qwen/qwen3-coder"]!;
NodeAssert.ok(model);
NodeAssert.equal(model.id, "qwen/qwen3-coder");
NodeAssert.equal(model.providerID, "openrouter");
});
});

describe("parseAgentListCliOutput", () => {
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/provider/opencodeRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,13 @@ export function parseModelsCliOutput(stdout: string): {
};

for (const line of lines) {
const slugMatch = SLUG_LINE_RE.exec(line);
// A model's JSON body is a single `JSON.stringify` line starting with `{`,
// while a provider/model slug is a bare `provider/model` header. Only the
// latter can be a slug: without this guard a body line with no interior
// whitespace and a `/` in one of its values (e.g. an OpenRouter model whose
// `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against
// an empty body and the model is silently dropped.
const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line);
if (slugMatch) {
flushModel();
currentSlug = slugMatch[1]!;
Expand Down
32 changes: 31 additions & 1 deletion apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,44 @@ import {
HostProcessEnvironment,
HostProcessPlatform,
} from "@t3tools/shared/hostProcess";
import { assert, describe, it } from "@effect/vitest";
import { afterEach, assert, describe, expect, it, vi } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";

import { ServerConfig } from "../config.ts";
import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts";

describe("ResourceMonitorBinary", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it.effect("skips Linux libc detection on Windows", () =>
Effect.gen(function* () {
const getReport = vi.spyOn(process.report, "getReport").mockImplementation(() => {
throw new Error("Linux libc detection must not run on Windows");
});
const fileSystem = yield* FileSystem.FileSystem;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-resource-monitor-binary-",
});
const binaryPath = `${baseDir}/t3-resource-monitor.exe`;
yield* fileSystem.writeFileString(binaryPath, "binary");

const service = yield* ResourceMonitorBinary.make().pipe(
Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
Effect.provideService(HostProcessPlatform, "win32"),
Effect.provideService(HostProcessArchitecture, "arm64"),
Effect.provideService(HostProcessEnvironment, {
T3CODE_RESOURCE_MONITOR_PATH: binaryPath,
}),
);

assert.equal(yield* service.resolve, binaryPath);
expect(getReport).not.toHaveBeenCalled();
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("resolves an executable override", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export function resourceMonitorPlatformKey(
export function resourceMonitorRustTarget(
platform: NodeJS.Platform,
architecture: NodeJS.Architecture,
linuxLibc: ResourceMonitorLinuxLibc,
linuxLibc?: ResourceMonitorLinuxLibc,
): string | undefined {
if (platform === "darwin") {
return architecture === "arm64"
Expand Down Expand Up @@ -142,7 +142,7 @@ export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(fu
const platform = yield* HostProcessPlatform;
const architecture = yield* HostProcessArchitecture;
const environment = yield* HostProcessEnvironment;
const linuxLibc = yield* ResourceMonitorHostLinuxLibc;
const linuxLibc = platform === "linux" ? yield* ResourceMonitorHostLinuxLibc : undefined;
const executableName = binaryName(platform);
const platformKey = resourceMonitorPlatformKey(platform, architecture);
const rustTarget = resourceMonitorRustTarget(platform, architecture, linuxLibc);
Expand Down
32 changes: 30 additions & 2 deletions apps/server/src/terminal/NodePtyAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const testLayer = NodePtyAdapter.layer.pipe(

it.effect("spawns through the public adapter with the provided host references", () =>
Effect.gen(function* () {
spawn.mockClear();
const adapter = yield* PtyAdapter.PtyAdapter;
const process = yield* adapter.spawn({
shell: "powershell.exe",
Expand All @@ -52,8 +53,35 @@ it.effect("spawns through the public adapter with the provided host references",
cwd: "C:\\workspace",
cols: 120,
rows: 40,
env: {},
name: "xterm-color",
env: { TERM: "xterm-256color" },
name: "xterm-256color",
},
]);
}).pipe(Effect.provide(testLayer)),
);

it.effect("preserves a caller-provided TERM in the spawn env on win32", () =>
Effect.gen(function* () {
spawn.mockClear();
const adapter = yield* PtyAdapter.PtyAdapter;
yield* adapter.spawn({
shell: "powershell.exe",
cwd: "C:\\workspace",
cols: 80,
rows: 24,
env: { TERM: "xterm-direct" },
});

assert.equal(spawn.mock.calls.length, 1);
assert.deepEqual(spawn.mock.calls[0], [
"powershell.exe",
[],
{
cwd: "C:\\workspace",
cols: 80,
rows: 24,
env: { TERM: "xterm-direct" },
name: "xterm-256color",
},
]);
}).pipe(Effect.provide(testLayer)),
Expand Down
11 changes: 9 additions & 2 deletions apps/server/src/terminal/NodePtyAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,14 +141,21 @@ export const make = Effect.fn("NodePtyAdapter.make")(function* (
return PtyAdapter.PtyAdapter.of({
spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) {
yield* ensureNodePtySpawnHelperExecutableCached;
// node-pty only writes `name` into the child's TERM on the Unix path;
// the ConPTY path leaves the environment untouched, so Windows children
// inherit a missing or 16-color TERM unless it is set here.
const env =
platform === "win32" && input.env["TERM"] === undefined
? { ...input.env, TERM: "xterm-256color" }
: input.env;
const ptyProcess = yield* Effect.try({
try: () =>
nodePty.spawn(input.shell, input.args ?? [], {
cwd: input.cwd,
cols: input.cols,
rows: input.rows,
env: input.env,
name: platform === "win32" ? "xterm-color" : "xterm-256color",
env,
name: "xterm-256color",
}),
catch: (cause) =>
new PtyAdapter.PtySpawnError({
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,23 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
}),
);

it.effect("reports remote status on unborn HEAD without failing", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* driver.initRepo({ cwd });
const initialBranch = yield* git(cwd, ["symbolic-ref", "--short", "HEAD"]);

const status = yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false });

assert.equal(status.isRepo, true);
assert.equal(status.branch, initialBranch);
assert.equal(status.hasUpstream, false);
assert.equal(status.aheadCount, 0);
assert.equal(status.behindCount, 0);
}),
);

it.effect("can read cached remote divergence without fetching upstream", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
Expand Down
38 changes: 24 additions & 14 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1506,25 +1506,35 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
if (branchResult === null) {
return NON_REPOSITORY_REMOTE_STATUS_DETAILS;
}
let branch: string | null;
if (branchResult.exitCode !== 0) {
if (isNonRepositoryGitStderr(branchResult.stderr)) {
return NON_REPOSITORY_REMOTE_STATUS_DETAILS;
}
return yield* new GitCommandError({
...gitCommandContext({
operation: "GitVcsDriver.statusDetailsRemote.branch",
cwd,
args: ["rev-parse", "--abbrev-ref", "HEAD"],
}),
detail: "Git branch lookup failed.",
exitCode: branchResult.exitCode,
stdoutLength: branchResult.stdout.length,
stderrLength: branchResult.stderr.length,
});
}
if (!isUnbornHeadStderr(branchResult.stderr)) {
return yield* new GitCommandError({
...gitCommandContext({
operation: "GitVcsDriver.statusDetailsRemote.branch",
cwd,
args: ["rev-parse", "--abbrev-ref", "HEAD"],
}),
detail: "Git branch lookup failed.",
exitCode: branchResult.exitCode,
stdoutLength: branchResult.stdout.length,
stderrLength: branchResult.stderr.length,
});
}

const branchValue = branchResult.stdout.trim();
const branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null;
const branchValue = yield* runGitStdout(
"GitVcsDriver.statusDetailsRemote.unbornBranch",
cwd,
["symbolic-ref", "--quiet", "--short", "HEAD"],
);
branch = branchValue.trim() || null;
} else {
const branchValue = branchResult.stdout.trim();
branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null;
}
const upstream = yield* resolveCurrentUpstream(cwd);
const upstreamRef = upstream?.upstreamRef ?? null;
let aheadCount = 0;
Expand Down
Loading
Loading