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
5fe86fb
feat: add voice dictation beta
t3-code[bot] Aug 2, 2026
55961ea
fix: simplify voice dictation providers
t3-code[bot] Aug 2, 2026
1f8a731
fix: split transcription input errors
t3-code[bot] Aug 2, 2026
64d1eba
fix: bound transcription response processing
t3-code[bot] Aug 2, 2026
521fa69
fix: follow effect recovery conventions
t3-code[bot] Aug 2, 2026
04d18fb
feat: improve voice dictation configuration and recording
maria-rcks Aug 2, 2026
0ff9fca
feat: match Codex voice dictation on web and mobile
KachurPro Aug 14, 2026
143c11e
fix: harden voice transcription state transitions
KachurPro Aug 14, 2026
edb4c35
fix: recover voice setup after transient cancellation
KachurPro Aug 14, 2026
b93fde1
fix: cancel in-flight voice transcription
KachurPro Aug 14, 2026
cc2c006
fix: keep web voice cancellation retryable
KachurPro Aug 14, 2026
692d3f0
fix: preserve stop control during active turns
KachurPro Aug 14, 2026
78fc154
feat(mobile): configure voice transcription provider
KachurPro Aug 14, 2026
63b51f5
fix: harden voice dictation controls
KachurPro Aug 14, 2026
9f7da13
fix(web): preserve dictation focus on mobile
KachurPro Aug 14, 2026
ade713e
fix: guard voice dictation terminal races
KachurPro Aug 14, 2026
d372663
fix(web): preserve voice composer interactions
KachurPro Aug 14, 2026
4732d3a
refactor(web): use primary HTTP layer for transcription
KachurPro Aug 14, 2026
c3b084f
fix: preserve voice settings and send guards
KachurPro Aug 14, 2026
ff3706c
fix(web): theme voice send action
KachurPro Aug 14, 2026
c239c12
merge #6625: feat: add Codex-style voice dictation to web and mobile …
sheehanmunim Aug 18, 2026
b763844
feat(mcp): let agents send tasks to another connected computer
sheehanmunim Aug 18, 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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Your existing subscriptions are used directly — MT Code sells nothing and adds
- **Resume on restart** — threads and agents that were running when the app closed automatically pick up where they left off at the next launch.
- **Cross-thread references** — type `#` in the composer to reference another thread; the agent can read that thread's transcript.
- **Thread-to-thread messaging** — agents can list sibling threads and send messages between them, so parallel work can coordinate.
- **Agent-chosen computers** — agents can send a task to another machine already connected in MT Code (this computer, SSH, T3 Connect, or a paired backend) without you changing **Run on**.
- **Better "Open in editor" on macOS** — detects Cursor, VS Code Insiders, VSCodium, Trae, Kiro, and JetBrains IDEs by their app bundles, even when their CLI shims aren't installed.
- **Installs alongside official T3 Code** — its own bundle ID (`com.munim.t3code`) and its own isolated data directory, so it never touches the official app's settings or sessions.

Expand All @@ -48,6 +49,7 @@ Full docs live in [docs/](./docs):
- [Desktop notifications](./docs/user/desktop-notifications.md)
- [Remote access from a phone or another machine](./docs/user/remote-access.md)
- [Thread messaging](./docs/user/thread-messaging.md)
- [Sending work to another computer](./docs/user/computer-routing.md)
- [Keeping app and server in sync](./docs/user/updating.md)
- [Source control integrations](./docs/user/source-control.md)
- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md)
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,13 @@ const config: ExpoConfig = {
},
plugins: [
"expo-asset",
[
"expo-audio",
{
microphonePermission: "Allow T3 Code to access your microphone for voice dictation.",
enableBackgroundRecording: false,
},
],
[
"expo-font",
{
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"effect": "catalog:",
"expo": "~56.0.12",
"expo-asset": "~56.0.17",
"expo-audio": "~56.0.13",
"expo-auth-session": "~56.0.14",
"expo-blur": "~56.0.3",
"expo-build-properties": "~56.0.19",
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
import { createStaticNavigation } from "@react-navigation/native";

import { RegistryContext } from "@effect/atom-react";
import { ComputerTaskHosts } from "./components/ComputerTaskHosts";
import { ConfirmDialogHost } from "./components/ConfirmDialogHost";
import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene";
Expand Down Expand Up @@ -97,6 +98,7 @@ function AppContent() {
<Navigation linking={appLinking} theme={navigationTheme} />
</IncomingShareProvider>
<ConfirmDialogHost />
<ComputerTaskHosts />
</BlurTargetView>
{/* Anchored-menu overlays render here — in-window, so the
keyboard stays up while a dropdown is open. */}
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnv
import { SettingsEnvironmentRenameRouteScreen } from "./features/settings/SettingsEnvironmentRenameRouteScreen";
import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
import { SettingsVoiceDictationRouteScreen } from "./features/settings/SettingsVoiceDictationRouteScreen";
import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
Expand Down Expand Up @@ -185,6 +186,13 @@ const SettingsContentStack = createNativeStackNavigator({
title: "Project Grouping",
},
}),
SettingsVoiceDictation: createNativeStackScreen({
screen: SettingsVoiceDictationRouteScreen,
linking: "voice-dictation",
options: {
title: "Voice Dictation",
},
}),
SettingsClientStorage: createNativeStackScreen({
screen: SettingsClientStorageRouteScreen,
linking: "client-storage",
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
IconLetterSpacing,
IconLink,
IconMessage,
IconMicrophone,
IconMinus,
IconMoon,
IconNetwork,
Expand Down Expand Up @@ -126,6 +127,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
"line.3.horizontal.decrease.circle": IconFilter,
"line.3.horizontal.decrease.circle.fill": IconFilter,
magnifyingglass: IconSearch,
mic: IconMicrophone,
paintbrush: IconPalette,
"person.crop.circle": IconUserCircle,
pin: IconPin,
Expand Down
244 changes: 244 additions & 0 deletions apps/mobile/src/components/ComputerTaskHosts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { RegistryContext, useAtomValue } from "@effect/atom-react";
import {
computerPeerFromPresentation,
ComputerTaskDispatchError,
matchProject,
requireMatchedProject,
} from "@t3tools/client-runtime/computers";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import {
MessageId,
ThreadId,
type ComputerPeer,
type ComputerTaskHost as ComputerTaskHostPayload,
type ComputerTaskSendRequest,
type ComputerTaskSendResult,
type ComputerTaskStreamEvent,
type EnvironmentId,
type OrchestrationShellSnapshot,
} from "@t3tools/contracts";
import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";

import { environmentCatalog } from "../connection/catalog";
import { computerEnvironment } from "../state/computers";
import { useEnvironments } from "../state/environments";
import { environmentPresentations } from "../state/presentation";
import { environmentSnapshotAtom } from "../state/shell";
import { threadEnvironment } from "../state/threads";
import { useAtomCommand } from "../state/use-atom-command";

const TARGET_READY_TIMEOUT_MS = 20_000;
const TARGET_READY_POLL_MS = 200;

function createComputerTaskClientId(): string {
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
return `computers-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
}

function randomId(): string {
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

type RequestStreamResult<E> = AsyncResult.AsyncResult<ComputerTaskStreamEvent, E>;

function createComputerTaskRequestConsumerAtom<E>(options: {
readonly requestsAtom: Atom.Atom<RequestStreamResult<E>>;
readonly clientId: ComputerTaskHostPayload["clientId"];
readonly handle: (request: ComputerTaskSendRequest) => Promise<ComputerTaskSendResult>;
readonly respond: (response: {
readonly clientId: ComputerTaskHostPayload["clientId"];
readonly connectionId: string;
readonly requestId: string;
readonly ok: boolean;
readonly result?: ComputerTaskSendResult;
readonly error?: {
readonly code:
| "dispatch_failed"
| "project_not_found"
| "project_ambiguous"
| "computer_offline";
readonly detail: string;
};
}) => Promise<unknown>;
}): Atom.Atom<void> {
return Atom.make((get) => {
const consume = (result: RequestStreamResult<E>) => {
if (!AsyncResult.isSuccess(result)) return;
const event = result.value;
if (event.type === "connected") return;
void options.handle(event.request).then(
(value) =>
options.respond({
clientId: options.clientId,
connectionId: event.connectionId,
requestId: event.request.requestId,
ok: true,
result: value,
}),
(error) =>
options.respond({
clientId: options.clientId,
connectionId: event.connectionId,
requestId: event.request.requestId,
ok: false,
error: {
code: error instanceof ComputerTaskDispatchError ? error.code : "dispatch_failed",
detail:
error instanceof Error
? error.message
: "Could not start the task on that computer.",
},
}),
);
};
const initial = get.once(options.requestsAtom);
get.subscribe(options.requestsAtom, consume);
queueMicrotask(() => consume(initial));
}).pipe(Atom.withLabel(`computer-task-consumer:${options.clientId}`));
}

export function ComputerTaskHosts() {
const { environments } = useEnvironments();
const computers = useMemo(
() => environments.map((environment) => computerPeerFromPresentation(environment)),
[environments],
);
if (environments.length === 0) return null;
return (
<>
{environments.map((environment) => (
<ComputerTaskHost
key={environment.environmentId}
environmentId={environment.environmentId}
computers={computers}
/>
))}
</>
);
}

function ComputerTaskHost(props: {
readonly environmentId: EnvironmentId;
readonly computers: ReadonlyArray<ComputerPeer>;
}) {
const { environmentId, computers } = props;
const registry = useContext(RegistryContext);
const [clientId] = useState(createComputerTaskClientId);
const [connectHost] = useState<ComputerTaskHostPayload>(() => ({ clientId, computers }));
const requestsAtom = computerEnvironment.requests({ environmentId, input: connectHost });
const respond = useAtomCommand(computerEnvironment.respond, { reportFailure: false });
const sync = useAtomCommand(computerEnvironment.sync, { reportFailure: false });
const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
const retryNow = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false });
const thisPresentation = useAtomValue(environmentPresentations.presentationAtom(environmentId));
useAtomValue(environmentSnapshotAtom(environmentId));

useEffect(() => {
if (thisPresentation?.connection.phase !== "connected") return;
void sync({ environmentId, input: { clientId, computers } });
}, [clientId, computers, environmentId, sync, thisPresentation?.connection.phase]);

const handleRequest = useCallback(
async (request: ComputerTaskSendRequest): Promise<ComputerTaskSendResult> => {
const targetId = request.computer.environmentId;
const livePresentation = registry
.get(environmentPresentations.presentationsAtom)
.get(targetId);
if (livePresentation && livePresentation.connection.phase !== "connected") {
await retryNow(targetId);
}
const snapshot = await waitForTargetSnapshot(registry, targetId);
const matched = requireMatchedProject(
matchProject({
projects: snapshot.projects,
hint: request.projectHint,
sourceTitle: request.source.projectTitle,
sourceWorkspaceRoot: request.source.projectWorkspaceRoot,
}),
);
const threadId = ThreadId.make(randomId());
const messageId = MessageId.make(randomId());
const createdAt = new Date().toISOString();
const startResult = await startTurn({
environmentId: targetId,
input: {
threadId,
message: {
messageId,
role: "user",
text: request.message,
attachments: [],
},
modelSelection: request.modelSelection,
titleSeed: request.title,
runtimeMode: request.runtimeMode,
interactionMode: request.interactionMode,
bootstrap: {
createThread: {
projectId: matched.id,
title: request.title,
modelSelection: request.modelSelection,
runtimeMode: request.runtimeMode,
interactionMode: request.interactionMode,
branch: null,
worktreePath: null,
createdAt,
},
},
createdAt,
},
});
if (startResult._tag === "Failure") {
throw squashAtomCommandFailure(startResult);
}
return {
environmentId: targetId,
threadId,
projectId: matched.id,
};
},
[registry, retryNow, startTurn],
);

const consumerAtom = useMemo(
() =>
createComputerTaskRequestConsumerAtom({
requestsAtom,
clientId,
handle: handleRequest,
respond: (response) => respond({ environmentId, input: response }),
}),
[clientId, environmentId, handleRequest, requestsAtom, respond],
);
useAtomValue(consumerAtom);
return null;
}

async function waitForTargetSnapshot(
registry: AtomRegistry.AtomRegistry,
targetId: EnvironmentId,
): Promise<OrchestrationShellSnapshot> {
const deadline = Date.now() + TARGET_READY_TIMEOUT_MS;
let lastPhase: string | null = null;
while (Date.now() < deadline) {
const presentation = registry.get(environmentPresentations.presentationsAtom).get(targetId);
lastPhase = presentation?.connection.phase ?? null;
const snapshot = registry.get(environmentSnapshotAtom(targetId));
if (lastPhase === "connected" && snapshot !== null) {
return snapshot;
}
await sleep(TARGET_READY_POLL_MS);
}
throw new ComputerTaskDispatchError(
"computer_offline",
lastPhase === "connected"
? "That computer connected but T3 has not loaded its projects yet."
: "Could not reach that computer. Keep T3 Code running there, or pick it in Run on first.",
);
}
16 changes: 16 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ import { SettingsRow } from "./components/SettingsRow";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";
import {
activeMobileVoiceTranscriptionConfig,
useMobileVoiceTranscriptionSettings,
} from "../voice-dictation/voiceTranscriptionSettings";

type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
Expand Down Expand Up @@ -535,10 +539,22 @@ function GeneralSettingsSection() {
preferencesResult.value.autoSettleOnMerge !== false;
const steerActiveTurns =
!AsyncResult.isSuccess(preferencesResult) || preferencesResult.value.steerActiveTurns !== false;
const voiceTranscription = useMobileVoiceTranscriptionSettings();
const voiceTranscriptionConfig = activeMobileVoiceTranscriptionConfig(voiceTranscription);

return (
<SettingsSection title="General">
<SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
<SettingsRow
icon="mic"
label="Voice Dictation"
value={
voiceTranscription.loaded && voiceTranscriptionConfig.apiKey.trim().length > 0
? "Enabled"
: "Set up"
}
target="SettingsVoiceDictation"
/>
<SettingsSwitchRow
icon="arrow.triangle.branch"
label="Auto-settle merged threads"
Expand Down
Loading
Loading