Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
eeb6e23
fix(web): keep Pi thinking controls visible before selection (#27397)
voltcrash Nov 7, 2026
67fdc53
fix(desktop): published themes reach the app when it uses this machin…
not-compromised Nov 7, 2026
e344153
fix(antigravity): check account access after installation (#32446)
Yash-Singh1 Nov 7, 2026
f016a16
fix(acp): preserve native form questions and typed answers (#19659)
StiensWout Nov 7, 2026
9f00ced
fix(antigravity): explain missing SOCKS dependency during refresh (#1…
StiensWout Nov 7, 2026
4c3a9d5
fix(cursor): use the SDK account for usage data (#32448)
Yash-Singh1 Nov 7, 2026
917ce99
fix(web): agent sign-in defaults to Auto access (#32186)
flamboh Nov 7, 2026
a601caa
fix(gitlab): `glab mr accept` counts as a shell merge (#32400)
felixlaga Nov 7, 2026
97d04c3
fix(forgejo): tea's `pull` and `m` aliases count as shell merges (#32…
felixlaga Nov 7, 2026
e455200
fix: side chats start with the parent's permission mode and reasoning…
Iweisc Nov 7, 2026
25bf84a
fix(mobile): center the Choose project empty-state buttons (#27450)
juliusmarminge Nov 7, 2026
511238c
fix(mobile): Android terminal sends hardware-keyboard arrows, Tab, an…
only21mil Nov 7, 2026
0080084
fix(mobile): group settled PR threads in the continue menu (#27941)
arifali123 Nov 7, 2026
af96a01
fix(mobile): shorten the model picker save action (#32551)
PixPMusic Nov 7, 2026
a9e709f
fix(mobile): center Android header confirmation buttons (#32239)
PixPMusic Nov 7, 2026
bc3d2e3
chore(sync): merge upstream main and preserve fork integrations
ScriptedAlchemy Nov 7, 2026
80bb433
fix(test): provide the theme environment in composer mocks
ScriptedAlchemy Nov 8, 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: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getClientSettings, setClientSettings } from "./methods/clientSettings.t
import { getConnectionCatalog, setConnectionCatalog } from "./methods/connectionCatalog.ts";
import { discoverLanServices } from "./methods/lanDiscovery.ts";
import {
getHostEnvironmentId,
getLocalEnvironmentEnabled,
setLocalEnvironmentEnabled,
} from "./methods/localEnvironment.ts";
Expand Down Expand Up @@ -101,6 +102,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handleSync(getLocalEnvironmentBootstraps);
yield* ipc.handleSync(getLocalEnvironmentEnabled);
yield* ipc.handle(setLocalEnvironmentEnabled);
yield* ipc.handle(getHostEnvironmentId);
yield* ipc.handle(getLocalEnvironmentBearerToken);

yield* ipc.handle(getClientSettings);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL = "desktop:get-preferred-sys
export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps";
export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled";
export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled";
export const GET_HOST_ENVIRONMENT_ID_CHANNEL = "desktop:get-host-environment-id";
export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL =
"desktop:get-local-environment-bearer-token";
export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
Expand Down
29 changes: 28 additions & 1 deletion apps/desktop/src/ipc/methods/localEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { assert, describe, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

Expand All @@ -10,7 +13,11 @@ import * as ElectronApp from "../../electron/ElectronApp.ts";
import * as ElectronTheme from "../../electron/ElectronTheme.ts";
import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
import * as DesktopWindow from "../../window/DesktopWindow.ts";
import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled } from "./localEnvironment.ts";
import {
getHostEnvironmentId,
getLocalEnvironmentEnabled,
setLocalEnvironmentEnabled,
} from "./localEnvironment.ts";

const layerUnusedLifecycleRuntime = Layer.mergeAll(
DesktopShutdown.layer,
Expand All @@ -27,6 +34,26 @@ const layerUnusedLifecycleRuntime = Layer.mergeAll(
);

describe("local environment IPC", () => {
it.effect("reads the shared home identity without creating one", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const stateDir = yield* fs.makeTempDirectoryScoped();
const environment = DesktopEnvironment.DesktopEnvironment.of({
path,
stateDir,
} as DesktopEnvironment.DesktopEnvironment["Service"]);
const read = getHostEnvironmentId
.handler(undefined)
.pipe(Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment));
assert.strictEqual(yield* read, null);
assert.isFalse(yield* fs.exists(path.join(stateDir, "environment-id")));
yield* fs.writeFileString(path.join(stateDir, "environment-id"), " service-id\n");
assert.strictEqual(yield* read, "service-id");
yield* fs.writeFileString(path.join(stateDir, "environment-id"), "\n");
assert.strictEqual(yield* read, null);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
it.effect("relaunches only when the setting changes and keeps other settings", () => {
const relaunchReasons: Array<string> = [];
const layer = Layer.mergeAll(
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/ipc/methods/localEnvironment.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Schema from "effect/Schema";

import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts";
import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
import * as IpcChannels from "../channels.ts";
Expand Down Expand Up @@ -28,3 +30,18 @@ export const setLocalEnvironmentEnabled = makeIpcMethod({
}
}),
});

// A background service sharing this app's T3 home records its environment ID in the same state dir.
export const getHostEnvironmentId = makeIpcMethod({
channel: IpcChannels.GET_HOST_ENVIRONMENT_ID_CHANNEL,
payload: Schema.Void,
result: Schema.NullOr(Schema.String),
handler: Effect.fn("desktop.ipc.localEnvironment.getHostEnvironmentId")(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const contents = yield* fileSystem
.readFileString(environment.path.join(environment.stateDir, "environment-id"))
.pipe(Effect.orElseSucceed(() => ""));
return contents.trim() || null;
}),
});
1 change: 1 addition & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false,
setLocalEnvironmentEnabled: (enabled) =>
ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled),
getHostEnvironmentId: () => ipcRenderer.invoke(IpcChannels.GET_HOST_ENVIRONMENT_ID_CHANNEL),
getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL),
setClientSettings: (settings) =>
ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,10 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
onInput(mapOf("data" to "\r"))
},
)
val navigationKeys = TerminalNavigationKeyListener { onInput(mapOf("data" to it)) }
inputView.setOnKeyListener { _, keyCode, event ->
if (readOnly) return@setOnKeyListener true
if (navigationKeys.onKey(inputView, keyCode, event)) return@setOnKeyListener true
if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false
when {
keyCode == KeyEvent.KEYCODE_DEL -> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package expo.modules.t3terminal

import android.view.KeyEvent
import android.view.View

internal class TerminalNavigationKeyListener(
private val send: (String) -> Unit
) : View.OnKeyListener {
override fun onKey(view: View?, keyCode: Int, event: KeyEvent): Boolean {
val sequence = sequenceFor(keyCode, event) ?: return false
if (event.action == KeyEvent.ACTION_DOWN) send(sequence)
// Unconsumed arrow and Tab key events move focus off the terminal input.
return true
}

// Matches iOS: these keys only without modifiers, plus Shift+Tab.
private fun sequenceFor(keyCode: Int, event: KeyEvent): String? = when {
keyCode == KeyEvent.KEYCODE_TAB && event.hasModifiers(KeyEvent.META_SHIFT_ON) -> "\u001b[Z"
!event.hasNoModifiers() -> null
keyCode == KeyEvent.KEYCODE_DPAD_UP -> "\u001b[A"
keyCode == KeyEvent.KEYCODE_DPAD_DOWN -> "\u001b[B"
keyCode == KeyEvent.KEYCODE_DPAD_RIGHT -> "\u001b[C"
keyCode == KeyEvent.KEYCODE_DPAD_LEFT -> "\u001b[D"
keyCode == KeyEvent.KEYCODE_TAB -> "\t"
keyCode == KeyEvent.KEYCODE_ESCAPE -> "\u001b"
else -> null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package expo.modules.t3terminal

import android.app.Activity
import android.view.KeyEvent
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36], manifest = Config.NONE)
class TerminalNavigationKeyListenerTest {
@Test
fun hardwareNavigationKeysReachThePtyAndKeepFocus() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val sent = StringBuilder()
val input = EditText(activity).apply {
setSingleLine(true)
isFocusableInTouchMode = true
setOnKeyListener(TerminalNavigationKeyListener { sent.append(it) })
}
val nextControl = Button(activity).apply { isFocusableInTouchMode = true }
val root = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
addView(input)
addView(nextControl)
}
activity.setContentView(root)
// Focus search below the input only finds controls that have bounds.
root.measure(
View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY),
)
root.layout(0, 0, 1080, 1920)
assertTrue(input.requestFocus())

val shift = KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON
val keys = listOf(
Triple(KeyEvent.KEYCODE_DPAD_UP, 0, "\u001b[A"),
Triple(KeyEvent.KEYCODE_DPAD_DOWN, 0, "\u001b[B"),
Triple(KeyEvent.KEYCODE_DPAD_RIGHT, 0, "\u001b[C"),
Triple(KeyEvent.KEYCODE_DPAD_LEFT, 0, "\u001b[D"),
Triple(KeyEvent.KEYCODE_TAB, 0, "\t"),
Triple(KeyEvent.KEYCODE_TAB, shift, "\u001b[Z"),
Triple(KeyEvent.KEYCODE_ESCAPE, 0, "\u001b"),
)
for ((keyCode, meta, expected) in keys) {
sent.clear()
input.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_DOWN, keyCode, 0, meta))
input.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, meta))
assertEquals("bytes for key $keyCode meta $meta", expected, sent.toString())
assertTrue("focus left the input after key $keyCode meta $meta", input.isFocused)
}

sent.clear()
val ctrl = KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON
input.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP, 0, ctrl))
assertEquals("modified arrows are left to the app, as on iOS", "", sent.toString())
}
}
16 changes: 9 additions & 7 deletions apps/mobile/src/components/ui/AppBar.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,15 @@ function AppBarView(props: AppBarProps & { readonly searchState?: SearchState })
</Menu>
));
const confirmButton = props.confirm ? (
<Button
label={props.confirm.label}
tone="text"
loading={props.confirm.loading}
disabled={props.confirm.disabled}
onPress={props.confirm.onPress}
/>
<View className="self-center">
<Button
label={props.confirm.label}
tone="text"
loading={props.confirm.loading}
disabled={props.confirm.disabled}
onPress={props.confirm.onPress}
/>
</View>
) : null;
const options =
props.embedded || props.sheet ? null : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,13 @@ export function PullRequestAgentRow(props: {
title={props.title ?? action.title}
icon="sparkles"
accessibilityLabel={props.title ?? action.title}
menu={agent.threads.length > 1}
menu={agent.chooseThread && !action.attributes.disabled}
onPress={
action.attributes.disabled || agent.threads.length > 1
? undefined
: () => agent.start(props.task)
action.attributes.disabled || agent.chooseThread ? undefined : () => agent.start(props.task)
}
/>
);
if (agent.threads.length <= 1 || action.attributes.disabled) return row;
if (!agent.chooseThread || action.attributes.disabled) return row;
return (
<Menu
title={props.title ?? action.title}
Expand Down
48 changes: 24 additions & 24 deletions apps/mobile/src/features/pull-requests/PullRequestControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Animated from "react-native-reanimated";
import { PR_ROW_LAYOUT } from "./pullRequestAnimations";
import { useNavigation } from "@react-navigation/native";
import type { PullRequestDetail } from "@t3tools/contracts";
import type { MenuAction } from "@react-native-menu/menu";
import type { ReactNode } from "react";
import { Platform, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
Expand Down Expand Up @@ -128,7 +129,7 @@ export function PullRequestActionToolbar(props: {
).changeRequest;
const threadLabel = agent.threads.length ? "Continue thread" : `Work on ${shortLabel}`;
const primaryLabel = pendingReview ? `Submit review · ${draft.comments.length}` : threadLabel;
const chooseThread = agent.threads.length > 1;
const chooseThread = agent.chooseThread;
const agentActions = agent.actions.filter((action) => action.task !== "ask");
const open = (mode: "review" | "comment") =>
navigation.navigate("PullRequestReview", { ...props.params, mode });
Expand Down Expand Up @@ -193,17 +194,6 @@ export function PullRequestActionToolbar(props: {
</View>
);
}
const nativeThreadItems = threadMenuItems.map((item) => (
<Toolbar.MenuAction
key={item.id}
icon={item.image}
subtitle={"subtitle" in item ? item.subtitle : undefined}
disabled={"attributes" in item && item.attributes.disabled}
onPress={() => agent.select(item.id)}
>
<Toolbar.Label>{item.title}</Toolbar.Label>
</Toolbar.MenuAction>
));
const primaryControl = showPrimary ? (
!pendingReview && chooseThread ? (
<Toolbar.Menu
Expand All @@ -222,7 +212,7 @@ export function PullRequestActionToolbar(props: {
disabled={primaryDisabled}
separateBackground
>
{nativeThreadItems}
{renderThreadMenuItems(threadMenuItems, agent.select)}
</Toolbar.Menu>
) : (
<Toolbar.Button
Expand Down Expand Up @@ -256,17 +246,7 @@ export function PullRequestActionToolbar(props: {
{agentActions.map((action) =>
chooseThread && !action.disabled ? (
<Toolbar.Menu key={action.task} title={action.title} icon={action.icon}>
{agent.threadActions(action.task).map((item) => (
<Toolbar.MenuAction
key={item.id}
icon={item.image}
subtitle={"subtitle" in item ? item.subtitle : undefined}
disabled={"attributes" in item && item.attributes.disabled}
onPress={() => agent.select(item.id)}
>
<Toolbar.Label>{item.title}</Toolbar.Label>
</Toolbar.MenuAction>
))}
{renderThreadMenuItems(agent.threadActions(action.task), agent.select)}
</Toolbar.Menu>
) : (
<Toolbar.MenuAction
Expand Down Expand Up @@ -308,3 +288,23 @@ export function PullRequestActionToolbar(props: {
</Toolbar>
);
}

function renderThreadMenuItems(items: MenuAction[], select: (id: string) => void): ReactNode {
return items.map((item) =>
item.subactions ? (
<Toolbar.Menu key={item.id} title={item.title} icon={item.image}>
{renderThreadMenuItems(item.subactions, select)}
</Toolbar.Menu>
) : (
<Toolbar.MenuAction
key={item.id}
icon={item.image}
subtitle={item.subtitle}
disabled={item.attributes?.disabled}
onPress={() => item.id && select(item.id)}
>
<Toolbar.Label>{item.title}</Toolbar.Label>
</Toolbar.MenuAction>
),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
appendPullRequestAgentPrompt,
pullRequestAgentPrompt,
pullRequestAgentThread,
pullRequestAgentThreadActions,
} from "./pullRequestAgentTasks";
import { IsoDateTime, ProjectId, ThreadId } from "@t3tools/contracts";
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
Expand All @@ -27,7 +28,30 @@ describe("PR agent tasks", () => {
archivedAt: IsoDateTime.make("2026-10-09T10:00:00.000Z"),
};
expect(pullRequestAgentThread([thread, archived])).toBeUndefined();
expect(pullRequestAgentThread([archived])).toBeUndefined();
const settled = { ...thread, settled: true };
expect(pullRequestAgentThread([settled])).toBeUndefined();
expect(pullRequestAgentThread([settled], settled.id)).toBe(settled);
expect(pullRequestAgentThread([thread, { ...settled, id: archived.id }])).toBeUndefined();
expect(pullRequestAgentThread([thread, archived], archived.id)).toBe(archived);
const actions = pullRequestAgentThreadActions([settled, archived], "ask", true);
expect(actions.map((action) => action.title)).toEqual([
"Start a new thread",
"Existing work",
"Settled threads",
]);
expect(actions[2]?.subactions).toMatchObject([
{ id: `ask:${settled.id}`, subtitle: "Settled thread" },
]);
expect(pullRequestAgentThreadActions([thread], "ask", false)).toMatchObject([
{ id: `ask:${thread.id}`, subtitle: "Continue thread" },
]);
expect(
pullRequestAgentThreadActions([settled], "feedback", false)[0]?.subactions,
).toMatchObject([{ id: `feedback:${settled.id}`, attributes: { disabled: false } }]);
expect(pullRequestAgentThreadActions([archived], "feedback", true)[1]).toMatchObject({
attributes: { disabled: true },
});
});
it("replaces the helper's own text when switching tasks, keeping user edits", () => {
const first = appendPullRequestAgentPrompt("My question", "explain", GITHUB);
Expand Down
Loading
Loading