Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4daec10
fix(server): pairing tokens work on Node versions that cannot bind bo…
chisewaguri Oct 8, 2026
61b9790
fix(mobile): HTML pages in a thread no longer trap scrolling on Andro…
SunkenInTime Oct 8, 2026
07a8b9e
fix(web): centered scrollers no longer shift when the scrollbar appea…
maria-rcks Oct 8, 2026
62a5d37
fix(web): distinguish thread search matches from code tints (#17263)
Yash-Singh1 Oct 8, 2026
f999eb2
fix(server): Pi extension wakes get an owned continuation turn (#17214)
StiensWout Oct 8, 2026
de72286
fix(server): Pi discovers optional T3 tools on demand (#17220)
StiensWout Oct 8, 2026
eb1cf7b
fix(web): stack merge dialog closes as soon as you confirm (#17116)
flamboh Oct 8, 2026
e8f5850
fix(server): Pi editor dialogs prefill the answer composer (#17206)
StiensWout Oct 8, 2026
a0066d5
fix(desktop): generate valid User-Agent that follows RFC 9110 guideli…
jztmanyl Oct 8, 2026
0b6ec43
fix(server): Pi discovers workspace skills and commands (#17190)
StiensWout Oct 8, 2026
805967a
fix(mobile): preserve navigation after native swipe back (#17268)
juliusmarminge Oct 8, 2026
fd25c42
fix(server): keep newly discovered models out of legacy groups (#14314)
Bil0000 Oct 8, 2026
df616cc
feat(editors): open remote projects in JetBrains IDEs over SSH (#17271)
juliusmarminge Oct 8, 2026
740f591
test(desktop): expect JetBrains IDEs among remote editors (#17291)
juliusmarminge Oct 8, 2026
cdd331b
fix(server): recognize authenticated GitHub Enterprise hosts (#11059)
alimek Oct 8, 2026
2a93885
fix(connect): relay client updates itself and skips incompatible clou…
juliusmarminge Oct 8, 2026
bd565bd
fix(shared): relay client install waits out a brief Windows file lock…
ScottN-PV Oct 8, 2026
c377d1c
fix(shared): release relay install locks on cancellation (#10585)
yashranaway Oct 8, 2026
af3d842
chore(shared): bump managed cloudflared to 2026.10.0 (#11184)
bompus Oct 8, 2026
5809487
fix(shared): bound cloudflared download with 10-minute timeout (#14139)
kvnloo Oct 8, 2026
9eea28b
refactor(provider-core): add provider-core and provider-testing packa…
juliusmarminge Oct 8, 2026
2c11607
refactor(settings): drop the legacy per-driver providers map (#17300)
juliusmarminge Oct 8, 2026
9b0df13
refactor(provider-pi): move Pi into its own provider package (#17302)
juliusmarminge Oct 8, 2026
7b57b72
feat(models): tell users when a CLI update unlocks a new model (#17307)
juliusmarminge Oct 8, 2026
48b71f0
fix(web): collapsed composer reserves room for wide send actions (#17…
maria-rcks Oct 8, 2026
d81afa0
fix(muse): workflow subagents no longer stall on hidden approvals (#1…
t3dotgg Oct 8, 2026
284969f
chore: 🔀 merge upstream nightly v0.0.46-nightly.20261008.2849
chamaquita-d3[bot] Oct 9, 2026
1374d5a
fix: align usage panel and desktop tests with merged upstream
chamaquita-d3[bot] Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
40 changes: 39 additions & 1 deletion apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ describe("DesktopAppIdentity", () => {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
yield* identity.configure;

assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]);
assert.deepEqual(calls.setName, ["T3 Code Alpha"]);
assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)");
assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3");
assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab");
Expand All @@ -237,6 +237,44 @@ describe("DesktopAppIdentity", () => {
);
});

it.effect.each([
{ stage: "Alpha", environment: {} },
{
stage: "Nightly",
environment: { appVersion: "0.0.43-nightly.20260929.2428" },
},
{
stage: "Dev",
environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } },
},
])("uses a valid native User-Agent product name for $stage", ({ stage, environment }) => {
const calls: ElectronAppCalls = {
setAboutPanelOptions: [],
setDockIcon: [],
setName: [],
};

return withIdentity(
Effect.gen(function* () {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
yield* identity.configure;

const runtimeName = calls.setName[0];
assert.isDefined(runtimeName);
// This fork brands its nightly builds as "D3 Code".
const baseName = stage === "Nightly" ? "D3 Code" : "T3 Code";
assert.equal(runtimeName, `${baseName} ${stage}`);
// RFC 9110's token grammar, after Electron removes ASCII spaces.
assert.match(runtimeName.replaceAll(" ", ""), /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/);
assert.equal(
calls.setAboutPanelOptions[0]?.applicationName,
stage === "Nightly" ? "D3 Code" : `T3 Code (${stage})`,
);
}),
{ calls, environment },
);
});

it.effect("sets the dock icon only when running unpackaged", () => {
const calls: ElectronAppCalls = {
setAboutPanelOptions: [],
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/app/DesktopAppIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,12 @@ export const make = Effect.gen(function* () {

const configure = Effect.gen(function* () {
const commitHash = yield* resolveAboutCommitHash;
yield* electronApp.setName(environment.displayName);
// Electron removes spaces from this name to build the native User-Agent
// product token, but leaves parentheses intact. Keep the runtime name valid
// without rewriting preview sessions (which breaks Turnstile, #7110).
yield* electronApp.setName(
`${environment.branding.baseName} ${environment.branding.stageLabel}`,
);
yield* electronApp.setAboutPanelOptions({
applicationName: environment.displayName,
applicationVersion: environment.appVersion,
Expand Down
22 changes: 20 additions & 2 deletions apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ describe("ElectronShell", () => {
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("opens the JetBrains Toolbox ssh deep link", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);

const url =
"jetbrains://gateway/ssh/environment?h=example.com&launchIde=true&ideHint=IU&projectHint=%2Fhome%2Fuser%2Fproject";
const electronShell = yield* ElectronShell.ElectronShell;
const result = yield* electronShell.openExternal(url);

assert.equal(result, true);
assert.deepEqual(openExternalMock.mock.calls, [[url]]);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("does not open editor URLs that mix up link shapes", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);
Expand All @@ -114,9 +128,12 @@ describe("ElectronShell", () => {
const results = yield* Effect.all([
electronShell.openExternal("zed://extension/attacker"),
electronShell.openExternal("vscode://ssh/example.com/home/user/project"),
electronShell.openExternal("jetbrains://idea/navigate/reference?project=x&path=y"),
electronShell.openExternal("jetbrains://gateway/ssh/environment?launchIde=true"),
electronShell.openExternal("jetbrains://gateway/com.example.provider/connect?h=x"),
]);

assert.deepEqual(results, [false, false]);
assert.deepEqual(results, [false, false, false, false, false]);
assert.equal(openExternalMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronShell.layer)),
);
Expand All @@ -134,9 +151,10 @@ describe("ElectronShell", () => {
"vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project",
),
electronShell.openExternal("zed://ssh/user@example.com/home/user/project"),
electronShell.openExternal("jetbrains://user@gateway/ssh/environment?h=example.com"),
]);

assert.deepEqual(results, [false, false, false]);
assert.deepEqual(results, [false, false, false, false]);
assert.equal(openExternalMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronShell.layer)),
);
Expand Down
14 changes: 9 additions & 5 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import * as Option from "effect/Option";
import * as Electron from "electron";

// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`,
// `zed://ssh/<host>/<path>`) must reach the OS handler; every other non-web
// scheme stays blocked.
// `zed://ssh/<host>/<path>`, `jetbrains://gateway/ssh/environment?h=…`) must
// reach the OS handler; every other non-web scheme stays blocked.
const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);
const REMOTE_EDITOR_PROTOCOLS = new Set(
REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => {
Expand All @@ -31,9 +31,13 @@ const isRemoteEditorUrl = (url: URL) =>
url.password.length === 0 &&
(url.protocol === "zed:"
? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname)
: url.host === "vscode-remote" &&
url.pathname.startsWith("/ssh-remote+") &&
url.pathname.length > "/ssh-remote+".length);
: url.protocol === "jetbrains:"
? url.host === "gateway" &&
url.pathname === "/ssh/environment" &&
(url.searchParams.get("h") ?? "").length > 0
: url.host === "vscode-remote" &&
url.pathname.startsWith("/ssh-remote+") &&
url.pathname.length > "/ssh-remote+".length);

export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
if (typeof rawUrl !== "string") {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,6 @@ it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")(
);
assert.include(editors, "cursor");
assert.include(editors, "vscode");
assert.notInclude(editors, "webstorm");
assert.include(editors, "webstorm");
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
36 changes: 35 additions & 1 deletion apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ const layerElectronMenu = (
const configureMenu = (
selectedAction: Deferred.Deferred<string>,
applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>,
environment: Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> = {},
) =>
Effect.gen(function* () {
const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
Expand All @@ -116,7 +117,7 @@ const configureMenu = (
Layer.provideMerge(layerElectronDialog),
Layer.provideMerge(layerElectronApp),
Layer.provideMerge(
DesktopEnvironment.layer(environmentInput).pipe(
DesktopEnvironment.layer({ ...environmentInput, ...environment }).pipe(
Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))),
),
),
Expand All @@ -125,6 +126,39 @@ const configureMenu = (
);

describe("DesktopApplicationMenu", () => {
it.effect("keeps display branding in the macOS application menu", () =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
const applicationMenuTemplate =
yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();

yield* configureMenu(selectedAction, applicationMenuTemplate, {
platform: "darwin",
appVersion: "0.0.43-nightly.20260929.2428",
});

const template = yield* Deferred.await(applicationMenuTemplate);
const applicationMenu = template[0];
assert.isDefined(applicationMenu);
assert.equal(applicationMenu.label, "D3 Code");
if (!Array.isArray(applicationMenu.submenu)) {
throw new Error("Expected application menu submenu to be an array.");
}
assert.equal(
applicationMenu.submenu.find((item) => item.role === "about")?.label,
"About D3 Code",
);
assert.equal(
applicationMenu.submenu.find((item) => item.role === "hide")?.label,
"Hide D3 Code",
);
assert.equal(
applicationMenu.submenu.find((item) => item.role === "quit")?.label,
"Quit D3 Code",
);
}),
);

it.effect("installs the native menu and routes Settings through DesktopWindow", () =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
Expand Down
11 changes: 4 additions & 7 deletions apps/desktop/src/window/DesktopApplicationMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import * as Schema from "effect/Schema";
import type * as Electron from "electron";

import { makeComponentLogger } from "../app/DesktopObservability.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as ElectronMenu from "../electron/ElectronMenu.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
Expand Down Expand Up @@ -107,10 +106,8 @@ const handleCheckForUpdatesMenuClick = Effect.gen(function* () {

/** @public Service construction is part of the canonical Effect module API. */
export const make = Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
const electronMenu = yield* ElectronMenu.ElectronMenu;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const appName = yield* electronApp.name;
const context = yield* Effect.context<DesktopApplicationMenuRuntimeServices>();
const runPromise = Effect.runPromiseWith(context);

Expand Down Expand Up @@ -157,9 +154,9 @@ export const make = Effect.gen(function* () {

if (environment.platform === "darwin") {
template.push({
label: appName,
label: environment.displayName,
submenu: [
{ role: "about" },
{ role: "about", label: `About ${environment.displayName}` },
{
label: "Check for Updates...",
click: checkForUpdatesClick,
Expand All @@ -173,11 +170,11 @@ export const make = Effect.gen(function* () {
{ type: "separator" },
{ role: "services" },
{ type: "separator" },
{ role: "hide" },
{ role: "hide", label: `Hide ${environment.displayName}` },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
{ role: "quit", label: `Quit ${environment.displayName}` },
],
});
}
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
"@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text",
"@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff",
"@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",
"@t3tools/provider-core": "workspace:*",
"@t3tools/provider-pi": "workspace:*",
"@t3tools/shared": "workspace:*",
"@tabler/icons-react-native": "^3.44.0",
"clsx": "^2.1.1",
Expand Down
23 changes: 14 additions & 9 deletions apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useState } from "react";
import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { AppText as Text } from "./AppText";
import { getProviderClient } from "../lib/providerClients";

type ProviderIconProps = {
readonly provider: string | null | undefined;
Expand Down Expand Up @@ -131,16 +132,20 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}

if (props.provider === "pi") {
const foreground = isDarkMode ? "#F5F5F5" : "#0F0F0F";
const packageIcon = getProviderClient(props.provider)?.icon;
if (packageIcon) {
const themeFill = (fill: { readonly light: string; readonly dark: string }) =>
isDarkMode ? fill.dark : fill.light;
return (
<Svg width={size} height={size} viewBox="165.29 165.29 469.43 469.43" fill="none">
<Path
fill={foreground}
fillRule="evenodd"
d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"
/>
<Path fill={foreground} d="M517.36 400H634.72V634.72H517.36Z" />
<Svg width={size} height={size} viewBox={packageIcon.viewBox} fill="none">
{packageIcon.paths.map((path) => (
<Path
key={path.d}
d={path.d}
fillRule={path.fillRule}
fill={themeFill(path.fill ?? packageIcon.fill)}
/>
))}
</Svg>
);
}
Expand Down
35 changes: 22 additions & 13 deletions apps/mobile/src/features/threads/HtmlRenderWebView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,26 @@ function postTheme(view: WebView<object> | null, theme: HtmlRenderTheme) {

const OVERFLOW_MESSAGE_TYPE = "t3-html-render-overflow";

// Reports whether the page overflows its frame, so a feed row only takes scroll
// gestures from a page that can use them.
const OVERFLOW_SCRIPT = `(function(){var last;function report(){var d=document.documentElement,b=document.body;var o=Math.max(d.scrollHeight,b?b.scrollHeight:0)>window.innerHeight+1||Math.max(d.scrollWidth,b?b.scrollWidth:0)>window.innerWidth+1;if(o===last)return;last=o;window.ReactNativeWebView.postMessage(JSON.stringify({type:${JSON.stringify(OVERFLOW_MESSAGE_TYPE)},overflow:o}));}report();if(window.ResizeObserver){var r=new ResizeObserver(report);r.observe(document.documentElement);if(document.body)r.observe(document.body);}window.addEventListener("resize",report);})();true;`;
// Reports which ways the page overflows its frame, so a feed row only takes
// scroll gestures from a page that can use them.
const OVERFLOW_SCRIPT = `(function(){var last;function report(){var d=document.documentElement,b=document.body;var x=Math.max(d.scrollWidth,b?b.scrollWidth:0)>window.innerWidth+1,y=Math.max(d.scrollHeight,b?b.scrollHeight:0)>window.innerHeight+1,o=x+","+y;if(o===last)return;last=o;window.ReactNativeWebView.postMessage(JSON.stringify({type:${JSON.stringify(OVERFLOW_MESSAGE_TYPE)},x:x,y:y}));}report();if(window.ResizeObserver){var r=new ResizeObserver(report);r.observe(document.documentElement);if(document.body)r.observe(document.body);}window.addEventListener("resize",report);})();true;`;

function readOverflowMessage(data: string) {
type Overflow = { readonly x: boolean; readonly y: boolean };

const NO_OVERFLOW: Overflow = { x: false, y: false };

function readOverflowMessage(data: string): Overflow | null {
try {
const message: unknown = JSON.parse(data);
return typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === OVERFLOW_MESSAGE_TYPE &&
"overflow" in message &&
typeof message.overflow === "boolean"
? message.overflow
"x" in message &&
typeof message.x === "boolean" &&
"y" in message &&
typeof message.y === "boolean"
? { x: message.x, y: message.y }
: null;
} catch {
return null;
Expand All @@ -88,7 +94,7 @@ export function HtmlRenderWebView(props: {
const [initialTheme] = useState(theme);
const [generation, setGeneration] = useState(0);
const [loaded, setLoaded] = useState(false);
const [overflows, setOverflows] = useState(false);
const [overflow, setOverflow] = useState(NO_OVERFLOW);
const webView = useRef<WebView<object>>(null);
const crashes = useRef(0);
// The theme the loaded document shows; null until it loads.
Expand All @@ -111,10 +117,10 @@ export function HtmlRenderWebView(props: {
}
shownTheme.current = null;
setLoaded(false);
setOverflows(false);
setOverflow(NO_OVERFLOW);
setGeneration((value) => value + 1);
};
const scrollable = !props.nested || overflows;
const scrollable = !props.nested || overflow.x || overflow.y;
// Pages have no horizontal padding of their own, so full screen adds the
// feed's gutter in the page's background color.
return (
Expand All @@ -141,7 +147,10 @@ export function HtmlRenderWebView(props: {
showsVerticalScrollIndicator={!props.nested}
showsHorizontalScrollIndicator={!props.nested}
scrollEnabled={scrollable}
nestedScrollEnabled={props.nested && overflows}
// Android only: the page holds vertical drags until it reaches an edge,
// then hands them to the feed (patches/react-native-webview). A page
// that only overflows sideways leaves vertical drags to the feed.
nestedScrollEnabled={props.nested && overflow.y}
overScrollMode={props.nested ? "never" : "always"}
// Only the page itself loads here; other top-frame navigations are
// dropped. A link the reader taps opens as a new window, which the
Expand All @@ -167,8 +176,8 @@ export function HtmlRenderWebView(props: {
? {
injectedJavaScript: OVERFLOW_SCRIPT,
onMessage: (event: WebViewMessageEvent) => {
const overflow = readOverflowMessage(event.nativeEvent.data);
if (overflow !== null) setOverflows(overflow);
const next = readOverflowMessage(event.nativeEvent.data);
if (next !== null) setOverflow(next);
},
}
: {})}
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/QuestionAttachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ export function QuestionAttachments(props: {
<TextInputWrapper onPaste={paste}>
<TextInput
value={props.value}
multiline={props.question.initialAnswer !== undefined}
submitBehavior={props.question.initialAnswer !== undefined ? "newline" : undefined}
editable={!props.disabled}
onChangeText={props.onChangeText}
onFocus={() => props.onInputFocusChange?.(true)}
Expand Down
Loading
Loading