Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6506528
fix(web): keep typed text when a question option is clicked (#12577)
cestercian Sep 19, 2026
55fc2fa
fix(server): empty Claude homePath shares continuation with ~/.claude…
cestercian Sep 19, 2026
a7feb0e
fix(server): surface ACP stderr when cursor-agent exits at session st…
cestercian Sep 19, 2026
f0b5318
fix(web): honor whitespace settings in pull request diffs (#12438)
Bil0000 Sep 20, 2026
6a2dd27
fix(web): keep citation comment when popover is dismissed (#10831)
flamboh Sep 20, 2026
447de94
fix(web): explain what enabling network access means in its confirmat…
Mnigos Sep 20, 2026
6acf4f0
fix(web): the Run on / Workspace menu closes after a pick (#12685)
Mnigos Sep 20, 2026
b22e2c7
fix: composer hero reads project name to screen readers (#12397)
Leos-Khai Sep 20, 2026
1a35d9c
fix(web): allow full contrast in assistant replies (#12405)
maria-rcks Sep 20, 2026
82644e8
fix(web): route keyboard submit through the primary worktree action (…
maria-rcks Sep 20, 2026
d2ae3be
fix(web): only show notice details when text is clipped (#12760)
t3-code[bot] Sep 20, 2026
6d449c4
fix(clients): keep backslashes in copied Codex citations (#12243)
Lucenx9 Sep 21, 2026
551d606
fix: allow more attachments without raising the image payload budget …
Sep 21, 2026
5c5422d
chore: version 0.0.40-skofo.20260921.curated13
Sep 21, 2026
b57876c
fix: repair curated13 fork integration typecheck errors
Sep 30, 2026
1005f91
fix(server): generate PR diffs from branch changes (#13170)
Yash-Singh1 Sep 23, 2026
5372321
fix(server): newer Codex models get T3 Code's instructions again (#13…
t3dotgg Sep 25, 2026
b02102d
fix(server): the SQLite WAL file shrinks back after large writes (#13…
t3dotgg Sep 26, 2026
df5a31d
perf(web): hidden terminal drawers no longer keep full thread history…
t3dotgg Sep 26, 2026
5c4b5c6
perf(clients): saving the thread list cache no longer freezes the UI …
t3dotgg Sep 26, 2026
06228de
fix(server): background git status fetches no longer fill the disk wi…
t3dotgg Sep 26, 2026
4b79a05
fix(web): skip unchanged shortcut modifier updates - 195x typing late…
callmemorgan Sep 28, 2026
ccf5bcb
fix(codex): Pro Max accounts load, so Ultrafast shows up (#14304)
t3dotgg Sep 29, 2026
01b6f26
test: keep reliability backports compatible with fork dependencies
Sep 30, 2026
d6835ed
test: isolate jsdom lock dependencies for reliability tests
Sep 30, 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
19 changes: 13 additions & 6 deletions apps/mobile/src/connection/environment-cache-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ConnectionPersistenceError,
EnvironmentCacheStore,
encodeShellSnapshotForCache,
} from "@t3tools/client-runtime/platform";
import {
type EnvironmentId,
Expand Down Expand Up @@ -50,7 +51,6 @@ const StoredVcsRefs = Schema.Struct({
const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(
Schema.fromJsonString(StoredShellSnapshot),
);
const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot));
const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(
Schema.fromJsonString(StoredThreadSnapshot),
);
Expand Down Expand Up @@ -129,11 +129,18 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
}),
),
saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {
const payload = yield* encodeStoredShellSnapshot({
schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,
environmentId,
snapshot,
}).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));
const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot).pipe(
Effect.mapError((cause) => persistenceError("save-shell", cause)),
);
const payload = yield* Effect.try({
try: () =>
JSON.stringify({
schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,
environmentId,
snapshot: encodedSnapshot,
} satisfies typeof StoredShellSnapshot.Encoded),
catch: (cause) => persistenceError("save-shell", cause),
});
yield* database
.saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload)
.pipe(Effect.mapError(mapDatabaseError("save-shell")));
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1131,7 +1131,7 @@ export function NewTaskDraftScreen(props: {
<Text className="text-2xl font-t3-medium tracking-tight text-foreground">in </Text>
<Pressable
accessibilityHint="Opens the project picker"
accessibilityLabel={`Change project from ${selectedProject.title}`}
accessibilityLabel={selectedProject.title}
accessibilityRole="button"
disabled={isComposerInteractionLocked}
onPress={chooseProject}
Expand Down
13 changes: 8 additions & 5 deletions apps/mobile/src/lib/composerFiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,10 +364,13 @@ describe("composer file attachments", () => {
it("applies the remaining attachment slots to photos and videos together", async () => {
mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] });

const result = await pickComposerMedia({ existingCount: 7, maxVideoBytes: 50 * 1024 * 1024 });
const result = await pickComposerMedia({
existingCount: 99,
maxVideoBytes: 50 * 1024 * 1024,
});

expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]);
expect(result.error).toBe("You can attach up to 8 attachments per message.");
expect(result.error).toBe("You can attach up to 100 attachments per message.");
expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ selectionLimit: 1 }));
expect(mocks.copy).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -512,9 +515,9 @@ describe("composer file attachments", () => {
});

it("does not open the picker when the draft has no remaining attachment slots", async () => {
await expect(pickComposerFiles({ existingCount: 8 })).resolves.toEqual({
await expect(pickComposerFiles({ existingCount: 100 })).resolves.toEqual({
files: [],
error: "You can attach up to 8 files per message.",
error: "You can attach up to 100 files per message.",
});

expect(mocks.pickFile).not.toHaveBeenCalled();
Expand Down Expand Up @@ -725,7 +728,7 @@ describe("composer file attachments", () => {
],
});

const result = await pickComposerFiles({ existingCount: 7, maxBytes: 1024 * 1024 });
const result = await pickComposerFiles({ existingCount: 99, maxBytes: 1024 * 1024 });

expect(result.files.map((file) => file.name)).toEqual(["report.pdf"]);
});
Expand Down
12 changes: 6 additions & 6 deletions apps/mobile/src/state/use-composer-drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ describe("mobile composer drafts", () => {
fileUri: `file:///documents/t3-composer-attachments/${id}.mov`,
});
const draftKey = "new-task:environment-1:project-cap";
const existing = Array.from({ length: 7 }, (_, index) => makeAttachment(`held-${index}`));
const existing = Array.from({ length: 99 }, (_, index) => makeAttachment(`held-${index}`));
appAtomRegistry.set(composerDraftsAtom, {
[draftKey]: { text: "send this", attachments: existing },
});
Expand All @@ -291,7 +291,7 @@ describe("mobile composer drafts", () => {

expect(rejected).toBe(1);
const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey];
expect(draft?.attachments).toHaveLength(8);
expect(draft?.attachments).toHaveLength(100);
expect(draft?.attachments.at(-1)?.id).toBe("incoming-1");
await cleanup.promise;
expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith(
Expand All @@ -305,7 +305,7 @@ describe("mobile composer drafts", () => {
{ allowOverflow: true },
);
expect(overflowRejected).toBe(0);
expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(9);
expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(101);
});

it("keeps shared attachment files until every draft releases them", async () => {
Expand Down Expand Up @@ -1481,17 +1481,17 @@ describe("mobile composer drafts", () => {
previewUri: "data:image/png;base64,YWJj",
});
const existingImage = image("existing");
const sharedImages = Array.from({ length: 8 }, (_, index) => image(`shared-${index}`));
const sharedImages = Array.from({ length: 100 }, (_, index) => image(`shared-${index}`));

const merged = mergeComposerDraftContentState(
{ [draftKey]: { text: "", attachments: [existingImage] } },
draftKey,
{ text: "", attachments: sharedImages },
);

expect(merged[draftKey]?.attachments).toHaveLength(8);
expect(merged[draftKey]?.attachments).toHaveLength(100);
expect(merged[draftKey]?.attachments[0]).toEqual(existingImage);
expect(merged[draftKey]?.attachments.at(-1)?.id).toBe("shared-6");
expect(merged[draftKey]?.attachments.at(-1)?.id).toBe("shared-98");
});

it("restores the exact draft captured before an interrupted share import", () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "t3",
"version": "0.0.40-skofo.20260919.curated9",
"version": "0.0.40-skofo.20260921.curated13",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
17 changes: 16 additions & 1 deletion apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3770,7 +3770,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("generates PR content against the remote base when the local base is stale", () =>
it.effect("generates PR content from branch changes when the remote base advances", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
Expand Down Expand Up @@ -3802,14 +3802,24 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
yield* runGit(repoDir, ["push", "-u", "origin", "feature/remote-base"]);
yield* runGit(repoDir, ["config", "branch.feature/remote-base.gh-merge-base", "main"]);

NodeFS.writeFileSync(NodePath.join(peerDir, "later-main.txt"), "unrelated\n");
yield* runGit(peerDir, ["add", "later-main.txt"]);
yield* runGit(peerDir, ["commit", "-m", "Later main commit"]);
yield* runGit(peerDir, ["push", "origin", "main"]);
yield* runGit(repoDir, ["fetch", "origin"]);

let generatedCommitSummary = "";
let generatedDiffSummary = "";
let generatedDiffPatch = "";
const { manager } = yield* makeManager({
ghScenario: {
prListSequence: ["[]", "[]"],
},
textGeneration: {
generatePrContent: (input) => {
generatedCommitSummary = input.commitSummary;
generatedDiffSummary = input.diffSummary;
generatedDiffPatch = input.diffPatch;
return Effect.succeed({ title: "Feature PR", body: "Feature body" });
},
},
Expand All @@ -3823,6 +3833,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
expect(result.pr.status).toBe("created");
expect(generatedCommitSummary).toContain("Feature commit");
expect(generatedCommitSummary).not.toContain("Remote base commit");
expect(generatedCommitSummary).not.toContain("Later main commit");
expect(generatedDiffSummary).toContain("feature.txt");
expect(generatedDiffSummary).not.toContain("later-main.txt");
expect(generatedDiffPatch).toContain("feature.txt");
expect(generatedDiffPatch).not.toContain("later-main.txt");
}),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts";
import {
ProviderAdapterProcessError,
ProviderAdapterRequestError,
ProviderAdapterValidationError,
ProviderWorkspaceMissingError,
Expand All @@ -55,6 +56,7 @@ import {
} from "../../serverSettings.ts";
import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError);
const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError);
const isProviderWorkspaceMissingError = Schema.is(ProviderWorkspaceMissingError);
Expand Down Expand Up @@ -393,6 +395,9 @@ const make = Effect.gen(function* () {
if (isProviderAdapterRequestError(failReason?.error)) {
return failReason.error.detail;
}
if (isProviderAdapterProcessError(failReason?.error)) {
return failReason.error.detail;
}
if (isProviderAdapterValidationError(failReason?.error)) {
return failReason.error.issue;
}
Expand Down
48 changes: 48 additions & 0 deletions apps/server/src/orchestration/Normalizer.attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ThreadId,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";

import * as ServerConfig from "../config.ts";
Expand Down Expand Up @@ -53,6 +54,53 @@ function turnStartCommand(input: {
}

describe("normalizeDispatchCommand attachments", () => {
it.effect("accepts 100 inline images and rejects 101 before writing files", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const attachments = Array.from({ length: 100 }, () => ({
dataUrl: "data:image/png;base64,cGl4ZWxz",
sizeBytes: 6,
}));
const rejected = yield* normalizeDispatchCommand(
turnStartCommand({ attachments: [...attachments, attachments[0]!] }),
).pipe(Effect.flip);
expect(rejected.message).toContain("up to 100");
expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
const accepted = yield* normalizeDispatchCommand(turnStartCommand({ attachments }));
if (accepted.type !== "thread.turn.start") throw new Error("Wrong command");
expect(accepted.message.attachments).toHaveLength(100);
expect(NodeFS.readdirSync(config.attachmentsDir)).toHaveLength(100);
}).pipe(Effect.provide(testLayer)),
);

it.effect("rejects decoded image overflow before writing it and removes earlier files", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
let writtenBytes = 0;
const dataUrl = `data:image/png;base64,${Buffer.alloc(10 * 1024 * 1024).toString("base64")}`;
const command = turnStartCommand({
attachments: [
...Array.from({ length: 8 }, () => ({ dataUrl, sizeBytes: 1 })),
{ dataUrl: "data:image/png;base64,YQ==", sizeBytes: 0 },
],
});
const error = yield* normalizeDispatchCommand(command).pipe(
Effect.provideService(FileSystem.FileSystem, {
...fileSystem,
writeFile: (path, data, options) => {
writtenBytes += data.byteLength;
return fileSystem.writeFile(path, data, options);
},
}),
Effect.flip,
);
expect(error.message).toContain("80 MiB");
expect(writtenBytes).toBe(80 * 1024 * 1024);
expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
}).pipe(Effect.provide(testLayer)),
);

it.effect("preserves inline image attachments from existing mobile clients", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/orchestration/Normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import {
type ClientOrchestrationCommand,
getProviderAttachmentLimitError,
type IsoDateTime,
type OrchestrationCommand,
OrchestrationDispatchCommandError,
Expand Down Expand Up @@ -133,10 +134,17 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
return canonicalCommand as OrchestrationCommand;
}

const attachmentLimitError = getProviderAttachmentLimitError(
canonicalCommand.message.attachments,
);
if (attachmentLimitError) {
return yield* new OrchestrationDispatchCommandError({ message: attachmentLimitError });
}
const claimedAttachmentPaths: string[] = [];
const attachmentsWithDecodedSizes = [...canonicalCommand.message.attachments];
const normalizedAttachments = yield* Effect.forEach(
canonicalCommand.message.attachments,
(attachment) =>
(attachment, index) =>
Effect.gen(function* () {
if (!("dataUrl" in attachment)) {
const claim = planAttachmentClaim({
Expand Down Expand Up @@ -226,6 +234,11 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
mimeType: parsed.mimeType.toLowerCase(),
sizeBytes: bytes.byteLength,
};
attachmentsWithDecodedSizes[index] = persistedAttachment;
const decodedLimitError = getProviderAttachmentLimitError(attachmentsWithDecodedSizes);
if (decodedLimitError) {
return yield* new OrchestrationDispatchCommandError({ message: decodedLimitError });
}

const attachmentPath = resolveAttachmentPath({
attachmentsDir: serverConfig.attachmentsDir,
Expand Down Expand Up @@ -253,6 +266,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
}),
),
);
claimedAttachmentPaths.push(attachmentPath);

return persistedAttachment;
}),
Expand Down
32 changes: 31 additions & 1 deletion apps/server/src/persistence/Layers/Sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as SqlClient from "effect/unstable/sql/SqlClient";

import { SqlitePersistenceMemory, makeSqlitePersistenceLive } from "./Sqlite.ts";
import {
SqlitePersistenceMemory,
WAL_SIZE_LIMIT_BYTES,
makeSqlitePersistenceLive,
} from "./Sqlite.ts";

const lockHolderSource = `
const { DatabaseSync } = require("node:sqlite");
Expand Down Expand Up @@ -57,6 +61,32 @@ it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", (
);
});

it.effect("shrinks the WAL file back to the size limit after a large write", () => {
const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-wal-"));
const dbPath = NodePath.join(tempDir, "state.sqlite");
const walFileSize = () => NodeFS.statSync(`${dbPath}-wal`).size;
// About 25% more 4 KB rows than the limit holds, in one transaction.
const rowCount = Math.ceil((WAL_SIZE_LIMIT_BYTES * 1.25) / 4000);

return Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`CREATE TABLE wal_probe(payload BLOB)`;
yield* sql`
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${rowCount})
INSERT INTO wal_probe(payload) SELECT randomblob(4000) FROM n
`;
assert.isAbove(walFileSize(), WAL_SIZE_LIMIT_BYTES);

// The auto-checkpoint after the large commit copied every frame into the
// database, so the next commit restarts the WAL and cuts the file back.
yield* sql`INSERT INTO wal_probe(payload) VALUES (x'00')`;
assert.isAtMost(walFileSize(), WAL_SIZE_LIMIT_BYTES);
}).pipe(
Effect.provide(makeSqlitePersistenceLive(dbPath).pipe(Layer.provide(NodeServices.layer))),
Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))),
);
});

it.effect("applies busy_timeout in the shared persistence setup", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/persistence/Layers/Sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import type { SqlError } from "effect/unstable/sql/SqlError";
import { runMigrations } from "../Migrations.ts";
import { ServerConfig } from "../../config.ts";

// Size the -wal file is cut back to on the first commit after a WAL reset.
export const WAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024;

type RuntimeSqliteLayerConfig = {
readonly filename: string;
readonly spanAttributes?: Record<string, unknown>;
Expand Down Expand Up @@ -37,6 +40,9 @@ const setup = Layer.effectDiscard(
yield* sql`PRAGMA busy_timeout = 5000;`;
yield* sql`PRAGMA foreign_keys = ON;`;
yield* sql`PRAGMA journal_mode = WAL;`;
// PASSIVE checkpoints never shrink the -wal file, so it otherwise keeps its
// largest size until the last connection closes.
yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`);
yield* runMigrations();
}),
);
Expand Down
Loading