Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
8c8ebdf
fix(shared): preserve code shielding and titled Markdown links
ElliotDrel Nov 4, 2026
965262e
fix(shared): preserve balanced code spans in thread link labels
ElliotDrel Nov 4, 2026
5ab91aa
fix(shared): preserve literal backticks in thread link labels
maria-rcks Nov 4, 2026
ad35912
fix(shared): shield bracket titles and later code spans
maria-rcks Nov 4, 2026
c744745
fix(shared): preserve escaped Markdown title delimiters
maria-rcks Nov 4, 2026
38ed00b
fix(shared): preserve whitespace after Markdown titles
maria-rcks Nov 4, 2026
aa2a4bb
refactor(shared): pair thread link label backticks once
ElliotDrel Nov 4, 2026
f45a87b
fix(shared): protect thread links using CommonMark code ranges
ElliotDrel Nov 4, 2026
c2175fc
perf(mobile): reuse prepared thread links across title updates
ElliotDrel Nov 4, 2026
15fd2dc
fix(shared): integrate main thread-link destination handling
ElliotDrel Nov 8, 2026
7550548
chore: merge latest main before thread-link handoff
ElliotDrel Nov 8, 2026
9cd7a04
fix(web): sending from an empty citation comment clears the composer …
juliusmarminge Nov 10, 2026
78201b0
fix(server): text generation fallback respects project provider setti…
Muzammil-Bit Nov 10, 2026
44ebb2f
feat(usage): show Muse Code limits, and xAI and Antigravity accounts …
StiensWout Nov 10, 2026
8fc6fd0
fix(web): preserve previews during attachment preparation (#33949)
Yash-Singh1 Nov 10, 2026
3725817
fix(desktop): input during window teardown no longer throws in the ma…
juliusmarminge Nov 10, 2026
d6d1763
fix(web): sent messages keep the numbers written on list items (#33820)
tsapa44 Nov 10, 2026
1a2a4a5
fix(server): quitting stops an agent-device daemon left by an earlier…
juliusmarminge Nov 10, 2026
3cb99ce
fix(mobile): attaching larger images no longer crashes with a BigInt …
juliusmarminge Nov 10, 2026
17a6a01
feat(mobile): pick a voice environment once, then its method (#34073)
juliusmarminge Nov 10, 2026
d91915d
fix(server): a t3 thread send to a finished delegated child notifies …
juliusmarminge Nov 10, 2026
43915a4
fix(web): question panel header text sits centered in its row (#34095)
juliusmarminge Nov 10, 2026
5f89ac7
fix(shared): merge owner thread-link updates preserving Markdown shie…
ElliotDrel Nov 10, 2026
ae14293
feat(scripts): scenarios prove a fix in the real app with e2e, on web…
juliusmarminge Nov 10, 2026
9e1f81c
feat(claude): Fable resets are offered and redeemed apart from weekly…
juliusmarminge Nov 10, 2026
04f6cfc
fix(desktop): desktop IPC only answers the main app window (#34094)
juliusmarminge Nov 10, 2026
f3437a2
fix(shared): merge owner linear thread-link label scan
ElliotDrel Nov 10, 2026
1897aee
test(shared): align owner backtick regression with CommonMark fences
ElliotDrel Nov 10, 2026
79bca43
feat(server): voice models reuse copies already on disk (#34096)
juliusmarminge Nov 11, 2026
1d80047
fix(web): scroll project picker to top on first result (#34113)
Yash-Singh1 Nov 11, 2026
1e92597
fix(shared): retain owner link escapes with CommonMark shielding
ElliotDrel Nov 11, 2026
3a8c250
feat(opencode): track native image job outcomes and previews (#34089)
Yash-Singh1 Nov 11, 2026
b883156
fix(scripts): scenarios run where localhost resolves to IPv6 first (#…
juliusmarminge Nov 11, 2026
768f17c
ci: browser secret helper apt step fetches fewer indexes (#33966)
t3dotgg Nov 11, 2026
d152d2c
fix(client-runtime): a search query pasted from github.com lists pull…
juliusmarminge Nov 11, 2026
4ca1039
fix(web): tighter outdated-conversation cards in the PR code panel (#…
Yash-Singh1 Nov 11, 2026
ccc7b2d
fix(web): MCP App cards no longer flash white in dark mode while the …
juliusmarminge Nov 11, 2026
92f1b8e
fix(peer): link requests use the addresses the app already has for a …
t3dotgg Nov 11, 2026
c90b678
fix(server): Find frees searched thread text after one idle minute (#…
t3dotgg Nov 11, 2026
f7b81e1
fix(web): settings search shortcuts follow the physical key on non-US…
juliusmarminge Nov 11, 2026
bbd51f7
fix(server): import Codex chats moved into a project from the scratch…
juliusmarminge Nov 11, 2026
5c97ada
fix(desktop): clear stale GPU caches when the Electron version change…
juliusmarminge Nov 11, 2026
b3fdf2b
fix(web): PR links jump to the comment, diff line, checks or commits …
Yash-Singh1 Nov 11, 2026
0c26118
Merge remote-tracking branch 'origin/main' into fix/thread-link-markd…
juliusmarminge Nov 11, 2026
e1fe230
test(shared): trim thread-link tests to regression cases
juliusmarminge Nov 11, 2026
5359f65
fix(shared): hosted thread URLs after strikethrough stay links
juliusmarminge Nov 11, 2026
26510e2
fix(shared): thread-link shielding reads GFM and bracketed thread ids
juliusmarminge Nov 11, 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
20 changes: 16 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,16 @@ jobs:
- name: Start installing browser secret helper build libraries
if: needs.changes.outputs.build != 'false'
run: |
sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
# Fetch only the indexes these main and universe packages need. The full set is ~42 MB,
# which a slow mirror can stretch past the wait below.
sudo sed -i -e 's|http://|https://|g' -e 's/^Components: .*/Components: main universe/' \
/etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
(
set +e
sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config xvfb
sudo apt-get update -o Acquire::Languages=none \
-o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \
-o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false &&
sudo apt-get install -y libsecret-1-dev pkg-config xvfb
echo "$?" > "$RUNNER_TEMP/apt-status"
) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null &

Expand Down Expand Up @@ -287,10 +293,16 @@ jobs:
# the background while Vite+ installs; the step before the tests waits for it.
- name: Start installing browser secret helper build libraries
run: |
sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
# Fetch only the indexes these main and universe packages need. The full set is ~42 MB,
# which a slow mirror can stretch past the wait below.
sudo sed -i -e 's|http://|https://|g' -e 's/^Components: .*/Components: main universe/' \
/etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
(
set +e
sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
sudo apt-get update -o Acquire::Languages=none \
-o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \
-o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false &&
sudo apt-get install -y libsecret-1-dev pkg-config
echo "$?" > "$RUNNER_TEMP/apt-status"
) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null &

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/mobile-showcase-screenshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ jobs:
axe --version

- name: Install foldable simulator controls
run: npm install --global agent-device@0.21.25
run: npm install --global agent-device@0.21.36

- name: Capture iOS showcase
run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}"
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ apps/mobile/.android-cache/
artifacts/app-store/screenshots/
artifacts/mobile-e2e/
artifacts/mobile-behavior/
scripts/.e2e/
.github/pr-assets/
native/**/target/
node_modules/
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real
- [Testing](docs/internals/testing.md) carries these same test rules, plus review guidance, for review bots. Change both together.
- **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite.
- Backend behavior changes ship with focused tests for that behavior.
- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript.
- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. For a bug a user can reproduce in the web client, `vp run scenarios:prove` shows it failing on base and passing on the fix in the real app; see [Scenarios](docs/operations/scenarios.md).
- The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. Concretely:
- `it.effect` runs on a `TestClock`; `Effect.sleep` only advances through `TestClock.adjust`. `it.live` is wall-clock and only for subprocess or network fixtures.
- No `yieldNow` or `sleep` polling loops. Await the `Deferred`, take from the `Queue`, or drain the worker.
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"electron-updater": "^6.8.9",
"electron-webauthn": "catalog:",
"ffi-rs": "1.3.2",
"playwright-core": "1.60.0",
"playwright-core": "1.64.1",
"react-grab": "^0.1.32",
"tldts": "catalog:"
},
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/app/DesktopClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ export const make = Effect.gen(function* () {
yield* electronApp.quit;
return yield* Effect.interrupt;
}
// Chromium opens these caches at the first event-loop turn, before ready.
yield* DesktopUserData.clearStaleGpuCaches({
userDataPath,
electronVersion: process.versions.electron,
});

const pendingSecondInstances: Array<readonly string[]> = [];
let handleSecondInstance = (argv: readonly string[]) => {
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/app/DesktopPreReadyFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export const make = FileSystem.makeNoop({
syncFs("makeDirectory", path, () => {
NodeFS.mkdirSync(path, { recursive: options?.recursive ?? false });
}),
remove: (path, options) =>
syncFs("remove", path, () =>
NodeFS.rmSync(path, {
recursive: options?.recursive ?? false,
force: options?.force ?? false,
}),
),
writeFileString: (path, data, options) =>
syncFs("writeFileString", path, () =>
NodeFS.writeFileSync(path, data, { flag: options?.flag ?? "w" }),
Expand Down
100 changes: 99 additions & 1 deletion apps/desktop/src/app/DesktopUserData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";

import { resolveUserDataPath } from "./DesktopUserData.ts";
import { clearStaleGpuCaches, resolveUserDataPath } from "./DesktopUserData.ts";

it.effect.each(["t3code", "T3 Code (Alpha)"])(
"preserves Windows credential keys from %s without copying browser databases",
Expand Down Expand Up @@ -40,3 +41,100 @@ it.effect.each(["t3code", "T3 Code (Alpha)"])(
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

const gpuCacheDirectories = [
"GPUCache",
"GrShaderCache",
"ShaderCache",
"DawnGraphiteCache",
"DawnWebGPUCache",
"GPUPersistentCache",
];

it.effect.each([
{ profile: "written by Electron 44.10.0", marker: "44.10.0" },
{ profile: "with no recorded Electron version", marker: undefined },
])("clears GPU caches $profile once when Electron 44.10.1 starts", ({ marker }) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const userDataPath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-gpu-cache-" });
for (const name of gpuCacheDirectories) {
yield* fs.makeDirectory(path.join(userDataPath, name), { recursive: true });
yield* fs.writeFileString(path.join(userDataPath, name, "data_0"), "44.10.0 shaders");
}
yield* fs.makeDirectory(path.join(userDataPath, "Local Storage"), { recursive: true });
yield* fs.writeFileString(path.join(userDataPath, "Local Storage", "LOCK"), "profile");
yield* fs.writeFileString(path.join(userDataPath, "Local State"), "profile");
if (marker !== undefined) {
yield* fs.writeFileString(path.join(userDataPath, "gpu-cache-electron-version"), marker);
}

yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" });

const survivingCaches: Array<string> = [];
for (const name of gpuCacheDirectories) {
if (yield* fs.exists(path.join(userDataPath, name))) survivingCaches.push(name);
}
assert.deepStrictEqual(survivingCaches, []);
assert.equal(
yield* fs.readFileString(path.join(userDataPath, "Local Storage", "LOCK")),
"profile",
);
assert.equal(yield* fs.readFileString(path.join(userDataPath, "Local State")), "profile");

yield* fs.makeDirectory(path.join(userDataPath, "GPUCache"));
yield* fs.writeFileString(path.join(userDataPath, "GPUCache", "data_0"), "44.10.1 shaders");
yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" });
assert.equal(
yield* fs.readFileString(path.join(userDataPath, "GPUCache", "data_0")),
"44.10.1 shaders",
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("keeps GPU caches built on the first launch of a fresh install", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const appData = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-fresh-" });
const userDataPath = path.join(appData, "t3code-v2");

yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" });
yield* fs.makeDirectory(path.join(userDataPath, "GPUCache"));
yield* fs.writeFileString(path.join(userDataPath, "GPUCache", "data_0"), "44.10.1 shaders");
yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" });

assert.equal(
yield* fs.readFileString(path.join(userDataPath, "GPUCache", "data_0")),
"44.10.1 shaders",
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("retries on the next launch when a GPU cache cannot be removed", () =>
Effect.gen(function* () {
const writes: Array<string> = [];
const fileSystem = FileSystem.makeNoop({
remove: (path) =>
Effect.fail(
PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "remove",
pathOrDescriptor: path,
}),
),
writeFileString: (path) =>
Effect.sync(() => {
writes.push(path);
}),
});

yield* clearStaleGpuCaches({ userDataPath: "/profile", electronVersion: "44.10.1" }).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
);

assert.deepStrictEqual(writes, []);
}).pipe(Effect.provide(NodeServices.layer)),
);
40 changes: 40 additions & 0 deletions apps/desktop/src/app/DesktopUserData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,43 @@ export const resolveUserDataPath = Effect.fn("desktop.userData.resolveUserDataPa
return destinationPath;
},
);

const GPU_CACHE_DIRECTORIES = [
"GPUCache",
"GrShaderCache",
"ShaderCache",
"DawnGraphiteCache",
"DawnWebGPUCache",
"GPUPersistentCache",
];

// Keyed on Electron, not Chromium: Electron patches ship Chromium backports under the same Chrome version.
export const clearStaleGpuCaches = Effect.fn("desktop.userData.clearStaleGpuCaches")(
function* (input: { readonly userDataPath: string; readonly electronVersion: string }) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const markerPath = path.join(input.userDataPath, "gpu-cache-electron-version");
const cachedVersion = yield* fs.readFileString(markerPath).pipe(
Effect.catchIf(
(error) => error.reason._tag === "NotFound",
() => Effect.undefined,
),
);
if (cachedVersion?.trim() === input.electronVersion) return;
for (const name of GPU_CACHE_DIRECTORIES) {
yield* fs.remove(path.join(input.userDataPath, name), { recursive: true, force: true });
}
yield* fs.makeDirectory(input.userDataPath, { recursive: true });
yield* fs.writeFileString(markerPath, input.electronVersion);
},
(effect, input) =>
effect.pipe(
Effect.catch((error) =>
Effect.logWarning("could not clear stale GPU caches", {
userDataPath: input.userDataPath,
electronVersion: input.electronVersion,
error: error.message,
}),
),
),
);
67 changes: 67 additions & 0 deletions apps/desktop/src/ipc/DesktopIpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import { assert, it } from "@effect/vitest";
import { vi } from "vite-plus/test";

vi.mock("electron", () => ({}));

import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopIpc from "./DesktopIpc.ts";

it.effect("answers only the main window's top frame", () =>
Effect.gen(function* () {
const mainFrame = {};
const invoke = new Map<string, DesktopIpc.DesktopIpcHandleListener>();
const sync = new Map<string, DesktopIpc.DesktopIpcSyncListener>();
const ipc = yield* DesktopIpc.DesktopIpc.pipe(
Effect.provide(
DesktopIpc.layer({
removeHandler: (channel) => void invoke.delete(channel),
handle: (channel, listener) => void invoke.set(channel, listener),
removeAllListeners: (channel) => void sync.delete(channel),
on: (channel, listener) => void sync.set(channel, listener),
}).pipe(
Layer.provide(
Layer.mock(ElectronWindow.ElectronWindow, {
main: Effect.succeedSome({
webContents: { id: 1, mainFrame },
} as unknown as Electron.BrowserWindow),
}),
),
),
),
);
yield* ipc.handle(
DesktopIpc.makeIpcMethod({
channel: "token",
payload: Schema.Void,
result: Schema.String,
handler: () => Effect.succeed("secret"),
}),
);
yield* ipc.handleSync({ channel: "token-sync", handler: () => Effect.succeed("secret") });

const senders = {
app: { sender: { id: 1 }, senderFrame: mainFrame },
appSubframe: { sender: { id: 1 }, senderFrame: {} },
previewGuest: { sender: { id: 2 }, senderFrame: {} },
};
const results: Record<string, unknown> = {};
for (const [name, sender] of Object.entries(senders)) {
const event = { ...sender, returnValue: "unset" as unknown };
sync.get("token-sync")!(event);
results[name] = [
yield* Effect.promise(() =>
Promise.resolve(invoke.get("token")!(sender, undefined)).catch(() => "rejected"),
),
event.returnValue,
];
}
assert.deepEqual(results, {
app: ["secret", "secret"],
appSubframe: ["rejected", undefined],
previewGuest: ["rejected", undefined],
});
}).pipe(Effect.scoped),
);
Loading
Loading