Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fd8e386
fix(mobile): the thread list shows server changes after a tab round t…
t3dotgg Nov 12, 2026
2b4a9c7
test(web): inline confirm tests no longer race the 3s disarm timer (#…
t3dotgg Nov 12, 2026
69dd4d0
test(web): Add Environment dialog test fills fields instead of typing…
t3dotgg Nov 12, 2026
4e48d9e
test(web): HTML preview accordion test waits for the frame's script b…
t3dotgg Nov 12, 2026
7c22095
fix(client-runtime): a stalled sign-in token or dead route no longer …
juliusmarminge Nov 12, 2026
34c5faa
fix(desktop): release-note entities named like Object.prototype keys …
juliusmarminge Nov 12, 2026
371963c
fix(desktop): startup log prune keeps files that rotation never names…
juliusmarminge Nov 12, 2026
f99b283
fix(provider-core): a timed-out status probe stops a CLI that ignores…
juliusmarminge Nov 12, 2026
49b858b
fix(git): selected-file commits work through symlinked subdirectories…
Kirat0201 Nov 12, 2026
833a15b
fix(opencode): a project opened mid-scan lists its OpenCode skills (#…
juliusmarminge Nov 12, 2026
a5bd0ba
fix(ssh): SSH setup on a host without gzip says gzip is missing (#35389)
juliusmarminge Nov 12, 2026
f03db4f
fix(pi): blank extension dialog titles no longer fail the turn (#35271)
Kirat0201 Nov 12, 2026
c5f5efd
fix(server): text-generation fallback keeps the thread default's cust…
juliusmarminge Nov 12, 2026
e96c96e
fix(server): storage cleanup stops holding usage-limited threads too …
juliusmarminge Nov 12, 2026
167d41c
fix(web): a numeric author or labels in the URL no longer crashes Pul…
juliusmarminge Nov 12, 2026
4d64163
fix(web): bare URLs end at Chinese and Japanese punctuation (#35267)
sanjaibalajee Nov 12, 2026
88bf393
fix(server): @ file search finds filenames containing literal braces …
sidhartha1s Nov 12, 2026
2a1e43e
fix(server): t3_thread_search says which thread is the caller's (#35377)
josephv123 Nov 12, 2026
a909ea4
perf(server): older history pages load without indexing the whole thr…
t3dotgg Nov 12, 2026
f4cf69f
fix(server): selecting a remote branch lands on its local branch (#35…
juliusmarminge Nov 12, 2026
a2685e6
fix(web): SCP-style Git remotes no longer become terminal file links …
juliusmarminge Nov 12, 2026
d0f9d4d
fix(web): sidebar draft previews show cited assistant text, not the r…
juliusmarminge Nov 12, 2026
8d9d204
Merge upstream main (d0f9d4dcc0) into integration/mcp-events-20261109
rallymewrx Nov 12, 2026
590ee8a
test(mcp): provide fork services for upstream thread search coverage
rallymewrx Nov 12, 2026
1e030bb
fix(server): preserve legacy post order in timeline windows
rallymewrx Nov 12, 2026
b94c6ba
Merge remote-tracking branch 'origin/integration/mcp-events-20261109'…
rallymewrx Nov 13, 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
21 changes: 21 additions & 0 deletions apps/desktop/src/app/DesktopObservability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,27 @@ describe("DesktopObservability", () => {
),
);

it.effect("startup prunes only canonical rotation suffixes", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const directory = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-rotating-prune-test-",
});
const filePath = `${directory}/server-child.log`;
const retained = ["1", "3", "04", "4.0", "4e0", "0x4", "+4", "notes"];
for (const suffix of [...retained, "4", "50"]) {
yield* fileSystem.writeFileString(`${filePath}.${suffix}`, suffix);
}

yield* DesktopObservability.makeRotatingLogFileWriter({ filePath, maxFiles: 3 });

assert.deepEqual(
(yield* fileSystem.readDirectory(directory)).sort(),
retained.map((suffix) => `server-child.log.${suffix}`).sort(),
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("stops every export when the OpenTelemetry SDK is disabled", () => {
const requests: Array<ExportedRequest> = [];
return Effect.gen(function* () {
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/app/DesktopObservability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,10 @@ export const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(
const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
for (const entry of entries) {
if (!entry.startsWith(`${baseName}.`)) continue;
const suffix = Number(entry.slice(baseName.length + 1));
if (!Number.isInteger(suffix) || suffix <= maxFiles) continue;
const suffixText = entry.slice(baseName.length + 1);
const suffix = Number(suffixText);
if (!Number.isInteger(suffix) || String(suffix) !== suffixText || suffix <= maxFiles)
continue;
yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore);
}
});
Expand Down
12 changes: 9 additions & 3 deletions apps/desktop/src/updates/releaseNotes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,14 +241,20 @@ describe("normalizeDesktopUpdateReleaseNotes", () => {
});
});

it("does not throw on out-of-range numeric entities and keeps the literal", () => {
it("keeps unsupported entities literal, including out-of-range and Object.prototype names", () => {
const result = normalizeDesktopUpdateReleaseNotes(
"- Broken entity &#9999999999;",
"- Broken entity &#9999999999;\n- Mentions &constructor; and &toString;",
"1.0.0",
"latest",
);
expect(result).toEqual({
releaseNotes: [{ version: "1.0.0", items: ["Broken entity &#9999999999;"], totalItems: 1 }],
releaseNotes: [
{
version: "1.0.0",
items: ["Mentions &constructor; and &toString;", "Broken entity &#9999999999;"],
totalItems: 2,
},
],
omittedReleaseCount: 0,
});
});
Expand Down
18 changes: 9 additions & 9 deletions apps/desktop/src/updates/releaseNotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ const MAX_RELEASE_NOTE_GROUPS = 6;
const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8;
const MAX_RELEASE_NOTE_ITEM_LENGTH = 220;

const HTML_ENTITY_REPLACEMENTS: Readonly<Record<string, string>> = {
amp: "&",
apos: "'",
gt: ">",
lt: "<",
nbsp: " ",
quot: '"',
};
const HTML_ENTITY_REPLACEMENTS: ReadonlyMap<string, string> = new Map([
["amp", "&"],
["apos", "'"],
["gt", ">"],
["lt", "<"],
["nbsp", " "],
["quot", '"'],
]);

function decodeCodePoint(codePoint: number, entity: string): string {
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
Expand All @@ -37,7 +37,7 @@ function decodeCodePoint(codePoint: number, entity: string): string {
}

function decodeHtmlEntity(entity: string): string {
const named = HTML_ENTITY_REPLACEMENTS[entity];
const named = HTML_ENTITY_REPLACEMENTS.get(entity);
if (named) return named;
if (entity.startsWith("#x")) {
return decodeCodePoint(Number.parseInt(entity.slice(2), 16), entity);
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -493,11 +493,11 @@ const RootStackConfig = createWorkspaceStackNavigator({
Home: createNativeStackScreen({
screen: WorkspaceRootScreen,
linking: "",
listeners: {
listeners: ({ navigation }) => ({
transitionStart: ({ data }) => recordHomeTransitionStart(data.closing),
transitionEnd: ({ data }) => recordHomeTransitionEnd(data.closing),
transitionEnd: ({ data }) => recordHomeTransitionEnd(data.closing, navigation.isFocused()),
focus: recordHomeFocus,
},
}),
options: {
...pageRoute({ duoMinimize: "never", root: true }),
contentStyle: { backgroundColor: "transparent" },
Expand Down
62 changes: 59 additions & 3 deletions apps/mobile/src/features/home/home-route-visibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import { act, useLayoutEffect } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vite-plus/test";

import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
import {
NativePrimaryColumnContext,
NativeWorkspaceTabActivityContext,
} from "../../native/v5-workspace-context";

// The Thread in the detail column is the focused route on iPad split view.
vi.mock("@react-navigation/native", () => ({
useNavigation: () => ({ isFocused: () => false }),
}));

import {
recordHomeFocus,
recordHomeTransitionEnd,
recordHomeTransitionStart,
useAtomValueWhileVisible,
Expand Down Expand Up @@ -54,6 +58,18 @@ function setup() {
),
);
},
/** A phone has no primary column: Home is the root page of its tab's slot. */
showHomeInTabSlot(active: boolean) {
act(() =>
root.render(
<RegistryContext.Provider value={registry}>
<NativeWorkspaceTabActivityContext value={{ active, isActive: () => active }}>
<Probe />
</NativeWorkspaceTabActivityContext>
</RegistryContext.Provider>,
),
);
},
[Symbol.dispose]() {
act(() => root.unmount());
registry.dispose();
Expand All @@ -71,11 +87,51 @@ it("keeps the sidebar thread list live after switching tabs beside a focused Thr

// Chats → PRs → Chats: Chats' column will-appear, then the PRs column's did-disappear.
act(() => {
recordHomeTransitionEnd(true);
recordHomeTransitionEnd(true, false);
recordHomeTransitionStart(false);
recordHomeTransitionEnd(true);
recordHomeTransitionEnd(true, false);
});
act(() => home.registry.set(shellsAtom, 2));

expect(home.latest, "new thread shells reach the sidebar list").toBe(2);
});

// Each phone tab slot shows its own copy of the Home route. PRs keeps Home focused; Settings
// is a route of its own.
it.each([
{ tab: "PRs", homeFocusedUnderTab: true },
{ tab: "Settings", homeFocusedUnderTab: false },
])(
"keeps the phone thread list live after a round trip through $tab",
({ homeFocusedUnderTab }) => {
using home = setup();
recordHomeFocus();
home.showHomeInTabSlot(true);
act(() => home.registry.set(shellsAtom, 1));
expect(home.latest).toBe(1);

// To the other tab: its slot will-appear, then the Chats slot's did-disappear.
act(() => {
recordHomeTransitionStart(false);
recordHomeTransitionEnd(true, homeFocusedUnderTab);
});
home.showHomeInTabSlot(false);
act(() => home.registry.set(shellsAtom, 2));
expect(home.latest, "the list under another tab holds its rows").toBe(1);

// Back to Chats: its slot will-appear, then the other slot's did-disappear comes last.
act(() => {
recordHomeFocus();
recordHomeTransitionStart(false);
recordHomeTransitionEnd(true, true);
});
home.showHomeInTabSlot(true);
act(() => home.registry.set(shellsAtom, 3));
expect(home.latest, "a server rename reaches the list").toBe(3);

// A pushed Thread takes the focus and still stops the list.
act(() => recordHomeTransitionEnd(true, false));
act(() => home.registry.set(shellsAtom, 4));
expect(home.latest, "the list under a Thread holds its rows").toBe(3);
},
);
16 changes: 13 additions & 3 deletions apps/mobile/src/features/home/home-route-visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { useNavigation } from "@react-navigation/native";
import { useContext, useRef, useSyncExternalStore } from "react";
import type { Atom } from "effect/reactivity";

import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
import {
NativePrimaryColumnContext,
NativeWorkspaceTabActivityContext,
} from "../../native/v5-workspace-context";

/**
* Home stays mounted under a pushed Thread, and native-stack never freezes the
Expand Down Expand Up @@ -34,8 +37,12 @@ export function recordHomeTransitionStart(closing: boolean): void {
if (!closing) setHomeVisible(true);
}

export function recordHomeTransitionEnd(closing: boolean): void {
if (closing) setHomeVisible(false);
/**
* Each iOS tab slot shows its own copy of the Home route, and the slot a tab switch leaves
* disappears last. So a disappear hides Home only when another route has the focus.
*/
export function recordHomeTransitionEnd(closing: boolean, homeFocused: boolean): void {
if (closing && !homeFocused) setHomeVisible(false);
}

/** Popping a sheet or a deep-linked route above Home has no appear event. */
Expand All @@ -46,10 +53,13 @@ export function recordHomeFocus(): void {
export function useHomeRouteVisible(): boolean {
const navigation = useNavigation();
const primaryColumn = useContext(NativePrimaryColumnContext);
const tabActive = useContext(NativeWorkspaceTabActivityContext)?.active ?? true;
const visible = useSyncExternalStore(subscribeHomeVisibility, () => homeVisible);
// Every tab slot's primary column reports to the one Home route, so a tab
// switch can end on the leaving slot's disappear. Trust this column instead.
if (primaryColumn) return primaryColumn.presented;
// The store is true for the Home route of every tab; this list shows in one of them.
if (!tabActive) return false;
// Before Home's first navigation event: a cold deep link mounts Home under
// its Thread without ever showing it.
return visible ?? navigation.isFocused();
Expand Down
62 changes: 61 additions & 1 deletion apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ import {
NodeId,
OrchestrationV2McpApprovalAnswer,
OrchestratorMcpThreadReadResult,
ProjectId,
ProviderDriverKind,
ProviderInstanceId,
ProjectId,
RunId,
ScheduledTaskError,
ScheduledTask,
Expand Down Expand Up @@ -77,6 +77,7 @@ import { dispatchFailure } from "../threadAccess.ts";
import { McpApprovalAnswerContext } from "../McpApprovalAnswerContext.ts";
import * as EnvironmentHandlers from "./environment/handlers.ts";
import * as AttachmentHandlers from "./attachment/handlers.ts";
import { ThreadToolkit } from "./thread/tools.ts";
import * as PeerForwarding from "../../peer/PeerForwarding.ts";
import * as PeerLinkRequests from "../../peer/PeerLinkRequests.ts";
import * as PeerLinks from "../../peer/PeerLinks.ts";
Expand All @@ -102,6 +103,9 @@ const encodeApprovalAnswer = Schema.encodeSync(
Schema.fromJsonString(OrchestrationV2McpApprovalAnswer),
);
const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult);
const decodeThreadSearchResult = Schema.decodeUnknownEffect(
ThreadToolkit.tools.t3_thread_search.successSchema,
);

const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe(
Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})),
Expand Down Expand Up @@ -1109,6 +1113,62 @@ it.effect("a client caller targets any thread within its ceiling and cannot act
),
);

it.effect("search names the calling thread, so an agent can tell its own match apart", () =>
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
const search = (invocation: McpInvocationContext.McpInvocationScope) =>
server
.callTool({ name: "t3_thread_search", arguments: { query: "release notes" } })
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
Effect.provideService(McpSchema.McpServerClient, client),
);

const fromThread = yield* search(scope);
expect(fromThread.structuredContent).toMatchObject({
currentThreadId: threadId,
matches: [{ threadId }, { threadId: "earlier-thread" }],
});

const fromClient = yield* search(clientScope("auto"));
expect(fromClient.structuredContent).toMatchObject({ currentThreadId: null });

// A linked environment on an older version answers without the field.
const fromOlderPeer = yield* decodeThreadSearchResult({ matches: [] });
expect(fromOlderPeer).toEqual({ matches: [] });
}).pipe(
Effect.provide(
McpHttpServer.layerThreadToolkit.pipe(
Layer.provideMerge(McpHttpServer.layerInProcessMcpServer),
Layer.provide(NodeCrypto.layer),
Layer.provide(McpToolAccessTestkit.liveThreadsLayer),
Layer.provide(
Layer.mock(ThreadSearch.ThreadSearch)({
searchThreads: () =>
Effect.succeed({
matches: [threadId, ThreadId.make("earlier-thread")].map((id) => ({
threadId: id,
projectId: ProjectId.make("project:mcp-test"),
title: "Release notes",
source: "title" as const,
snippet: "",
messageCreatedAt: null,
})),
}),
}),
),
Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})),
Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})),
Layer.provide(Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({})),
Layer.provide(Sqlite.layerMemory),
Layer.provide(NodeServices.layer),
),
),
),
);

it.effect("a read-only client reads threads and is refused every write before it runs", () =>
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/toolkits/thread/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, {
.searchThreads({ ...input, projectId: input.projectId ?? caller?.projectId })
.pipe(Effect.mapError(unavailable));
return {
currentThreadId: scope.thread?.threadId ?? null,
matches: matches.map((match) => ({
...match,
link: McpInvocationContext.threadLinkFor(scope, match.threadId, match.title),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/toolkits/thread/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ const ThreadSearchTool = Tool.make("t3_thread_search", {
projectId: Schema.optional(ProjectId),
}),
success: Schema.Struct({
currentThreadId: Schema.optional(Schema.NullOr(ThreadId)),
matches: Schema.Array(
Schema.Struct({
threadId: OrchestrationThreadSearchMatch.fields.threadId,
Expand Down
Loading
Loading