Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a5da327
fix(client-runtime): preserve cached turns and older-page loading (#8…
lnieuwenhuis Sep 15, 2026
844203d
chore(deps): bump Clerk stack to latest stable versions (#11956)
juliusmarminge Sep 15, 2026
b18a560
fix(mobile): update Reanimated and Worklets (#11957)
juliusmarminge Sep 15, 2026
96bddf8
fix(desktop): paste as text no longer doubles the pasted text (#11958)
TonybynMp4 Sep 15, 2026
719a76c
feat(web): choose queue or steer for follow-up messages (#11964)
Bil0000 Sep 15, 2026
e6ae764
feat(mobile): add v2 preview store builds (#11966)
juliusmarminge Sep 15, 2026
2c16c1d
fix(mobile): block incompatible server connections (#11974)
juliusmarminge Sep 15, 2026
f0a0ead
fix(web): keep PR controls readable in narrow panels (#11962)
Bil0000 Sep 15, 2026
b84f63b
fix(server): block updates under legacy service launchers (#11940)
Gigioxx Sep 15, 2026
f4600d7
fix: reduce GitHub quota use with sharing enabled (#11888)
Bil0000 Sep 15, 2026
87a12b5
fix(usage): refresh limits when the tab opens (#11928)
Bil0000 Sep 15, 2026
6f7aaff
fix(contracts): avoid Intl.Segmenter in monogram validation (Hermes c…
bompus Sep 15, 2026
37a8ab2
feat(lint): extend Hermes API bans with a configurable API list (#11982)
juliusmarminge Sep 15, 2026
b12c92f
fix(server): reuse Git index metadata during checkpoint capture (#10792)
im-kvijay Sep 15, 2026
7a368fe
refactor: give project monograms their own icon variant (#11993)
juliusmarminge Sep 16, 2026
935c55b
fix(clients): disable incompatible environments during discovery (#11…
juliusmarminge Sep 16, 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
35 changes: 32 additions & 3 deletions .github/workflows/mobile-eas-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ name: Mobile EAS Production
# into the void.
# workflow_dispatch remains as a manual override for both modes (e.g. to
# retry an errored build or force an OTA).
# Manual v2-preview builds keep the production app identity and disable OTA.
# Select the v2 branch when dispatching a preview build.
on:
workflow_dispatch:
inputs:
Expand All @@ -35,6 +37,14 @@ on:
options:
- build
- update
profile:
description: "Store build profile (v2-preview supports build mode only)"
required: true
type: choice
default: production
options:
- production
- v2-preview
platform:
description: "Target platform"
required: true
Expand Down Expand Up @@ -75,14 +85,20 @@ concurrency:

jobs:
production:
name: EAS Production ${{ github.event_name == 'push' && 'auto' || inputs.mode }}
name: EAS ${{ inputs.profile || 'production' }} ${{ github.event_name == 'push' && 'auto' || inputs.mode }}
runs-on: blacksmith-8vcpu-ubuntu-2404
permissions:
contents: read
env:
APP_VARIANT: production
NODE_OPTIONS: --max-old-space-size=8192
steps:
- name: Reject preview OTA updates
if: github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview' && inputs.mode == 'update'
run: |
echo "::error::V2 previews use store builds only. Select mode=build."
exit 1

- id: expo-token
name: Check for EXPO_TOKEN
env:
Expand Down Expand Up @@ -125,6 +141,16 @@ jobs:
args:
- --filter=@t3tools/mobile...

- name: Verify v2 preview source
if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview'
run: |
node --input-type=module -e '
import * as environment from "./packages/contracts/src/environment.ts";
if (environment.ORCHESTRATION_PROTOCOL_VERSION !== 2) {
throw new Error("V2 previews require a v2 source branch. Select the v2 branch when running this workflow.");
}
'

- name: Expose pnpm
if: steps.expo-token.outputs.present == 'true'
run: |
Expand Down Expand Up @@ -199,10 +225,12 @@ jobs:
- name: Summarize manual build version
if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build'
working-directory: apps/mobile
env:
MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
run: |
version="$(npx expo config --json --type public | jq -r '.version')"
{
echo "## Manual production build"
echo "## Manual $MOBILE_BUILD_PROFILE build"
echo
echo "- App version: \`$version\`"
echo "- Platform: \`${{ inputs.platform }}\`"
Expand All @@ -215,7 +243,8 @@ jobs:
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait
MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
run: eas build --platform ${{ inputs.platform }} --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait

- name: Publish OTA update (manual)
if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update'
Expand Down
31 changes: 31 additions & 0 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,37 @@ describe("DesktopApplicationMenu", () => {
}),
);

// Chromium pastes as plain text for the accelerator on its own. Dispatching
// the action as well injects a second paste, which doubles the pasted text.
it.effect("leaves the accelerator to Chromium instead of injecting a paste", () =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
const applicationMenuTemplate =
yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();

yield* configureMenu(selectedAction, applicationMenuTemplate);

const template = yield* Deferred.await(applicationMenuTemplate);
const editMenu = template.find((item) => item.label === "Edit");
if (!Array.isArray(editMenu?.submenu)) {
throw new Error("Expected Edit menu submenu to be an array.");
}
const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text");
if (typeof pasteAsTextItem?.click !== "function") {
throw new Error("Expected Paste as Text menu item to have a click handler.");
}

pasteAsTextItem.click(
{} as Electron.MenuItem,
{} as Electron.BrowserWindow,
{
triggeredByAccelerator: true,
} as unknown as KeyboardEvent,
);
assert.isFalse(yield* Deferred.isDone(selectedAction));
}),
);

// Zoom must route through DesktopWindow.zoomMain instead of the Electron
// zoom roles: the roles zoom whichever webContents has focus, which breaks
// app zoom while an embedded preview WebContentsView holds focus.
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/window/DesktopApplicationMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,17 @@ export const make = Effect.gen(function* () {
const settingsClick = () => {
runMenuEffect("open-settings", dispatchMenuAction("open-settings"));
};
const pasteAsTextClick = () => {
// Chromium already pastes as plain text for this chord, so the accelerator
// needs nothing from the menu: the composer and the terminal each arm
// themselves from the same keydown. Routing it through the renderer anyway
// lands a second, injected paste and doubles the text. Only a menu click,
// which produces no keystroke for them to see, needs that round trip.
const pasteAsTextClick = (
_item: Electron.MenuItem,
_window: Electron.BaseWindow | undefined,
event: Electron.KeyboardEvent,
) => {
if (event.triggeredByAccelerator === true) return;
runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text"));
};
const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => {
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@
"buildType": "apk"
}
},
"v2-preview": {
"extends": "production",
"distribution": "store",
"channel": "v2-preview",
"env": {
"T3CODE_MOBILE_UPDATES_ENABLED": "0"
}
},
"production": {
"corepack": true,
"env": {
Expand All @@ -52,6 +60,13 @@
}
},
"submit": {
"v2-preview": {
"extends": "production",
"android": {
"track": "alpha",
"releaseStatus": "completed"
}
},
"production": {
"ios": {
"ascAppId": "6787819824"
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,13 @@
"react-native-keyboard-controller": "1.21.13",
"react-native-nitro-markdown": "^0.5.0",
"react-native-nitro-modules": "0.35.9",
"react-native-reanimated": "4.5.1",
"react-native-reanimated": "4.5.5",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-shiki-engine": "^0.3.12",
"react-native-svg": "15.15.4",
"react-native-webview": "^13.16.1",
"react-native-worklets": "0.10.1",
"react-native-worklets": "0.11.4",
"shiki": "4.2.0",
"tailwind-merge": "^3.5.0",
"uniwind": "1.11.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EnvironmentId } from "@t3tools/contracts";
import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
import { describe, expect, it } from "vite-plus/test";

Expand All @@ -24,6 +24,32 @@ function relayStatus(
}

describe("available cloud environment presentation", () => {
it("shows an incompatible discovered server before any connection attempt", () => {
const onlineStatus = relayStatus("online");
const status = {
...onlineStatus,
descriptor: {
environmentId: onlineStatus.environmentId,
label: "Preview server",
platform: { os: "darwin", arch: "arm64" },
serverVersion: "2.0.0",
orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION + 1,
capabilities: { repositoryIdentity: true },
},
} satisfies RelayEnvironmentStatusResponse;
expect(
availableCloudEnvironmentPresentation({
isStatusPending: false,
status,
statusError: null,
statusErrorTraceId: null,
}),
).toMatchObject({
connectionState: "unsupported",
statusText: "Client not supported",
});
});

it("presents an online unsaved environment as available, not connected", () => {
expect(
availableCloudEnvironmentPresentation({
Expand Down
17 changes: 16 additions & 1 deletion apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import {
orchestrationProtocolCompatibilityError,
type EnvironmentConnectionPhase,
} from "@t3tools/client-runtime/connection";

export interface AvailableCloudEnvironmentPresentation {
readonly connectionError: string | null;
Expand All @@ -14,6 +17,18 @@ export function availableCloudEnvironmentPresentation(input: {
readonly statusError: string | null;
readonly statusErrorTraceId: string | null;
}): AvailableCloudEnvironmentPresentation {
const compatibilityError =
input.status?.descriptor === undefined
? null
: orchestrationProtocolCompatibilityError(input.status.descriptor);
if (compatibilityError !== null) {
return {
connectionError: compatibilityError.message,
connectionErrorTraceId: null,
connectionState: "unsupported",
statusText: "Client not supported",
};
}
if (input.status?.status === "online") {
return {
connectionError: null,
Expand Down
11 changes: 7 additions & 4 deletions apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,23 +219,25 @@ function ConnectedCloudEnvironmentRow(props: {
const serverConfig = useAtomValue(
serverEnvironment.configValueAtom(props.environment.environmentId),
);
const enabled = props.environment.isEnabled;
const unsupported = props.environment.connectionState === "unsupported";
const enabled = props.environment.isEnabled && !unsupported;
return (
<Pressable
accessibilityHint="Long press to remove from this device"
onLongPress={props.onRemove}
>
<CloudEnvironmentRowShell
borderTop={props.borderTop}
connectionError={enabled ? props.environment.connectionError : null}
connectionError={enabled || unsupported ? props.environment.connectionError : null}
connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null}
connectionState={enabled ? props.environment.connectionState : "available"}
connectionState={enabled || unsupported ? props.environment.connectionState : "available"}
errorExpanded={props.errorExpanded}
label={props.environment.environmentLabel}
machine={resolveEnvironmentMachineKind(serverConfig)}
onValueChange={props.onSetEnabled}
onToggleError={props.onToggleError}
{...(enabled ? {} : { statusText: "Off" })}
disabled={unsupported}
{...(enabled || unsupported ? {} : { statusText: "Off" })}
value={enabled}
/>
</Pressable>
Expand Down Expand Up @@ -270,6 +272,7 @@ function CloudEnvironmentRow(props: {
}
}}
onToggleError={props.onToggleError}
disabled={presentation.connectionState === "unsupported"}
statusText={presentation.statusText}
value={false}
/>
Expand Down
11 changes: 7 additions & 4 deletions apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { serverEnvironment } from "../../state/server";
import { ConnectionStatusDot } from "./ConnectionStatusDot";

function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
if (!environment.isEnabled) {
if (!environment.isEnabled && environment.connectionState !== "unsupported") {
return "Off";
}
return connectionStatusText({
Expand All @@ -46,10 +46,12 @@ export function ConnectionEnvironmentRow(props: {
const serverConfig = useAtomValue(
serverEnvironment.configValueAtom(props.environment.environmentId),
);
const enabled = props.environment.isEnabled;
const unsupported = props.environment.connectionState === "unsupported";
const enabled = props.environment.isEnabled && !unsupported;
const statusLabel = connectionStatusLabel(props.environment);
const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null;
const hasConnectionFailure = enabled && props.environment.connectionError !== null;
const hasConnectionFailure =
(enabled || unsupported) && props.environment.connectionError !== null;
const isRetrying =
enabled &&
(props.environment.connectionState === "connecting" ||
Expand Down Expand Up @@ -77,7 +79,7 @@ export function ConnectionEnvironmentRow(props: {
onPress={props.onToggle}
>
<ConnectionStatusDot
state={enabled ? props.environment.connectionState : "available"}
state={enabled || unsupported ? props.environment.connectionState : "available"}
pulse={isRetrying}
size={8}
/>
Expand Down Expand Up @@ -133,6 +135,7 @@ export function ConnectionEnvironmentRow(props: {
</View>

<ThemedSwitch
disabled={unsupported}
onValueChange={(next) => props.onSetEnabled(props.environment.environmentId, next)}
value={enabled}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function statusDotTone(state: ConnectionStatusDotState): {
haloColor: "rgba(245,158,11,0.5)",
};
case "offline":
case "unsupported":
case "error":
return {
dotColor: "#ef4444",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string
return `Connecting to ${environmentLabel}...`;
case "reconnecting":
return `Reconnecting to ${environmentLabel}...`;
case "unsupported":
return "Client not supported";
case "error":
return `${environmentLabel} is unavailable`;
case "available":
Expand All @@ -31,7 +33,7 @@ function noticeDetail(
error: string | null,
): string {
if (error) {
return `The app will keep retrying automatically. ${error}`;
return phase === "reconnecting" ? `The app will keep retrying automatically. ${error}` : error;
}

switch (phase) {
Expand All @@ -40,6 +42,8 @@ function noticeDetail(
case "connecting":
case "reconnecting":
return `The ${resourceName} will load as soon as the environment is ready.`;
case "unsupported":
return "Use compatible versions of the app and server to connect.";
case "available":
case "error":
return `Reconnect the environment to load the ${resourceName}.`;
Expand Down Expand Up @@ -95,7 +99,7 @@ export function EnvironmentConnectionNotice(props: {
) : null}
</Text>

{props.connection.phase !== "offline" ? (
{props.connection.phase !== "offline" && props.connection.phase !== "unsupported" ? (
<Pressable
accessibilityRole="button"
className="mt-1 rounded-full bg-subtle px-4 py-2.5 active:opacity-70"
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/features/connection/connectionTone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone {
pillClassName: "bg-primary/10",
textClassName: "text-foreground-secondary",
};
case "unsupported":
return {
label: "Client not supported",
pillClassName: "bg-danger",
textClassName: "text-danger-foreground",
};
case "error":
return {
label: "Connection failed",
Expand Down
Loading
Loading