Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c77a7b7
fix(web): align compact button touch targets (#17748)
Yash-Singh1 Oct 10, 2026
6266af3
fix(server): t3_thread_launch refuses a new worktree whose base ref h…
tris203 Oct 10, 2026
d6f3af7
fix(web): omit underlines on markdown image links (#17728)
Saikrishna1876 Oct 10, 2026
4606f9d
fix(server): restore auto resume for wrapped Claude gateway rate limi…
tzachbon Oct 10, 2026
e1ad595
fix(opencode): name OpenCode 2 sessions after their thread (#17414)
nkoynov Oct 10, 2026
472ea7e
fix(web): find update settings from the command palette (#17396)
sergical Oct 10, 2026
7b09ac7
fix(provider-opencode): tell OpenCode Zen and Go models apart (#17424)
mr-karan Oct 10, 2026
adbecda
fix(server): a pull that fast-forwards no longer fails on large Git o…
ScottN-PV Oct 10, 2026
91474e5
fix(web): cancel question auto-advance after navigation (#17364)
maxwellyoung Oct 10, 2026
33c1e36
fix(server): a bare repository name resolves to the signed-in account…
ScottN-PV Oct 10, 2026
71f6f1d
fix(web): a maximized right panel stays maximized when you return to …
jamesvillarrubia Oct 10, 2026
02612c2
fix(mobile): allow starting a task with only an image (#17409)
Claudesaul Oct 10, 2026
a418075
fix(server): PR watch no longer reports passed while a second run of …
ScottN-PV Oct 10, 2026
a5b4824
fix(server): say why a thread can't be settled (#17258)
DylanTX Oct 10, 2026
03b2105
fix(server): prevent busy terminals from starving history persistence…
StiensWout Oct 10, 2026
de49d63
feat(server): use macOS .icns app icons as project icons (#17149)
psv2522 Oct 10, 2026
a1dee8b
fix(mobile): usage reset icon lines up with its row (#17175)
Aforno Oct 10, 2026
ee7924f
fix(web): paths pasted after @ keep their underscores (#16619)
derektrimm Oct 10, 2026
dd4549e
fix(server): settle every OpenCode subagent call one report answers (…
nkoynov Oct 10, 2026
8bf0a38
perf(web): switching project keeps Diagnostics and Providers mounted …
flamboh Oct 10, 2026
f7664fd
perf(web): Open Source Licenses downloads its manifest once per sessi…
flamboh Oct 10, 2026
94d1a8e
fix(server): restore OpenCode adapter test typecheck (#17810)
Yash-Singh1 Oct 10, 2026
98beed1
fix(server): Claude subagents show the reasoning effort they run at (…
RakshithBhat03 Oct 10, 2026
8901e15
chore: merge upstream T3 Code at 98beed1a226c
github-actions[bot] Oct 10, 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: 1 addition & 1 deletion apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ describe("pickProjectFavicon", () => {
filters: [
{
name: "Images",
extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp"],
extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp", "icns"],
},
],
},
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
type DesktopEnvironmentBootstrap,
type PickedThemeFile,
} from "@t3tools/contracts";
import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview";
import { PROJECT_FAVICON_EXTENSIONS } from "@t3tools/shared/projectFavicon";
import { resolveEditorCommand } from "@t3tools/shared/editor";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as FileSystem from "effect/FileSystem";
Expand Down Expand Up @@ -270,7 +270,7 @@ export const pickProjectFavicon = DesktopIpc.makeIpcMethod({
filters: [
{
name: "Images",
extensions: WORKSPACE_IMAGE_PREVIEW_EXTENSIONS.map((extension) => extension.slice(1)),
extensions: PROJECT_FAVICON_EXTENSIONS.map((extension) => extension.slice(1)),
},
],
});
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1244,7 +1244,7 @@ export function NewTaskDraftScreen(props: {
if (
attachmentBlockReason !== null ||
!modelSelection ||
initialMessageText.length === 0 ||
(initialMessageText.length === 0 && draft.attachments.length === 0) ||
flow.submitting ||
(workspaceMode === "worktree" && !selectedBranchName)
) {
Expand Down Expand Up @@ -1397,7 +1397,7 @@ export function NewTaskDraftScreen(props: {
!modelUnavailable &&
Boolean(flow.selectedProject) &&
Boolean(flow.selectedModel) &&
flow.prompt.trim().length > 0 &&
(flow.prompt.trim().length > 0 || flow.attachments.length > 0) &&
isIncomingShareReady &&
!isImportingShare &&
!flow.submitting &&
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1116,7 +1116,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedEnvironmentServerConfig,
draft.modelSelection ?? null,
) ?? selectedModel;
if (text.length === 0 || !draftModelSelection) {
// A shared image or file is a task on its own; text is optional.
if ((text.length === 0 && draft.attachments.length === 0) || !draftModelSelection) {
return null;
}
// A saved choice from before the project went no-project must not
Expand Down
13 changes: 10 additions & 3 deletions apps/mobile/src/features/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,16 @@ function PoolWindowSummary({
</Text>
<View className="flex-row items-center gap-1">
{resetsIn ? (
<Text className="text-xs tabular-nums text-foreground-muted">
{resetsIn.replace("resets in ", "↻ ")}
</Text>
<>
<SymbolView
name="arrow.clockwise"
size={12}
tintColorClassName="accent-foreground-muted"
/>
<Text className="text-xs tabular-nums text-foreground-muted">
{resetsIn.replace("resets in ", "")}
</Text>
</>
) : null}
{credits ? (
<>
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/state/thread-outbox-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@ export function isQueuedThreadCreationSendable(message: QueuedThreadMessage): bo
if (!message.creation) {
return false;
}
if (message.text.trim().length === 0 || message.modelSelection === undefined) {
const hasContent = message.text.trim().length > 0 || message.attachments.length > 0;
if (!hasContent || message.modelSelection === undefined) {
return false;
}
return message.creation.workspaceMode !== "worktree" || Boolean(message.creation.branch);
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/state/thread-outbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1430,6 +1430,26 @@ describe("thread outbox", () => {
false,
);
expect(isQueuedThreadCreationSendable(base)).toBe(false);
expect(isQueuedThreadCreationSendable({ ...creationMessage, text: " " })).toBe(false);
expect(
isQueuedThreadCreationSendable({
...creationMessage,
text: "",
attachments: [
{
id: "image-1",
type: "image",
name: "photo.png",
mimeType: "image/png",
sizeBytes: 3,
fileUri: "file:///documents/t3-composer-attachments/photo.png",
previewUri: "file:///documents/t3-composer-attachments/photo.png",
uploadedAttachmentId: "pending-photo-png",
uploadEnvironmentId: EnvironmentId.make("environment-1"),
},
],
}),
).toBe(true);
});

it("retries transport failures but drops deterministic command failures", () => {
Expand Down
32 changes: 32 additions & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,38 @@ describe("AssetAccess", () => {
}).pipe(Effect.provide(layerTest)),
);

it.effect("serves a native macOS app icon as its embedded PNG", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-icns-",
});
const icns = new Uint8Array(8 + 8 + screenshotPng.length);
icns.set(new TextEncoder().encode("icns"));
new DataView(icns.buffer).setUint32(4, icns.length);
icns.set(new TextEncoder().encode("ic07"), 8);
new DataView(icns.buffer).setUint32(12, 8 + screenshotPng.length);
icns.set(screenshotPng, 16);
yield* fileSystem.makeDirectory(path.join(root, "Resources"));
yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), icns);

const result = yield* issueAssetUrl({
resource: { _tag: "project-favicon", cwd: root },
});
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");

expect(result.sourcePath).toBe(path.join("Resources", "AppIcon.icns"));
const asset = yield* resolveAsset(
suffix.slice(0, separatorIndex),
suffix.slice(separatorIndex + 1),
);
expect(asset?.kind === "bytes" ? asset.mimeType : null).toBe("image/png");
expect(asset?.kind === "bytes" ? Uint8Array.from(asset.bytes) : null).toEqual(screenshotPng);
}).pipe(Effect.provide(layerTest)),
);

it.effect("rejects a resolved project favicon with a non-image extension", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
36 changes: 30 additions & 6 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ import {
type ImageDimensions,
} from "@t3tools/shared/imageDimensions";
import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia";
import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
import {
isProjectFaviconPath,
PROJECT_FAVICON_FALLBACK_MARKER,
} from "@t3tools/shared/projectFavicon";
import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput";
import * as Clock from "effect/Clock";
import * as Crypto from "effect/Crypto";
Expand All @@ -57,6 +60,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
import { extractIcnsPng } from "./icns.ts";
import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts";
import * as HostProcess from "@t3tools/shared/HostProcess";

Expand All @@ -66,6 +70,8 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key";
const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
const PROJECT_FAVICON_VERSION_PREFIX = "v";
// Clients draw project icons from a 96px thumbnail at most.
const PROJECT_FAVICON_ICNS_MIN_SIZE = 96;
const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;
// Extensions a document viewer or audio player may request inline. The extension comes from
// the attachment id the server assigned, never from the client's mime type.
Expand Down Expand Up @@ -234,6 +240,26 @@ const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(functio
: { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") };
});

/** Serves an `.icns` favicon as its embedded PNG, since browsers cannot draw it. */
const projectFaviconAsset = Effect.fn("AssetAccess.projectFaviconAsset")(function* (
filePath: string,
) {
if (!filePath.toLowerCase().endsWith(".icns")) {
return { kind: "file", path: filePath } satisfies ResolvedAsset;
}
const fileSystem = yield* FileSystem.FileSystem;
const bytes = yield* fileSystem.readFile(filePath).pipe(
Effect.tapError((cause) =>
Effect.logError("Failed to read project icon image.", { filePath, cause }),
),
Effect.orElseSucceed(() => null),
);
const png = bytes ? extractIcnsPng(bytes, PROJECT_FAVICON_ICNS_MIN_SIZE) : null;
return png
? ({ kind: "bytes", bytes: png, mimeType: "image/png" } satisfies ResolvedAsset)
: null;
});

const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(function* (
filePath: string,
) {
Expand Down Expand Up @@ -634,7 +660,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
const relativePath =
faviconPath && !isExternalOverride ? path.relative(workspaceRoot, faviconPath) : null;
const sourceFaviconPath = isExternalOverride ? faviconPath : relativePath;
if (sourceFaviconPath && !isWorkspaceImagePreviewPath(sourceFaviconPath)) {
if (sourceFaviconPath && !isProjectFaviconPath(sourceFaviconPath)) {
return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
}
sourcePath = sourceFaviconPath ?? undefined;
Expand Down Expand Up @@ -837,7 +863,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
workspaceRoot: claims.workspaceRoot,
relativePath: claims.relativePath,
});
return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null;
return faviconPath ? yield* projectFaviconAsset(faviconPath) : null;
}

if (claims.kind === "project-favicon-external") {
Expand All @@ -850,9 +876,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
),
Effect.orElseSucceed(() => null),
);
return faviconPath === claims.filePath
? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset)
: null;
return faviconPath === claims.filePath ? yield* projectFaviconAsset(faviconPath) : null;
}

if (claims.kind === "github-media") {
Expand Down
62 changes: 62 additions & 0 deletions apps/server/src/assets/icns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vite-plus/test";

import { extractIcnsPng } from "./icns.ts";

function pngHeader(size: number): Uint8Array {
const png = new Uint8Array(24);
png.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
new DataView(png.buffer).setUint32(16, size);
new DataView(png.buffer).setUint32(20, size);
return png;
}

function icns(...entries: ReadonlyArray<readonly [type: string, data: Uint8Array]>): Uint8Array {
const length = 8 + entries.reduce((total, [, data]) => total + 8 + data.length, 0);
const bytes = new Uint8Array(length);
const view = new DataView(bytes.buffer);
bytes.set(new TextEncoder().encode("icns"));
view.setUint32(4, length);
let offset = 8;
for (const [type, data] of entries) {
bytes.set(new TextEncoder().encode(type), offset);
view.setUint32(offset + 4, 8 + data.length);
bytes.set(data, offset + 8);
offset += 8 + data.length;
}
return bytes;
}

const widthOf = (png: Uint8Array | null) =>
png ? new DataView(png.buffer, png.byteOffset).getUint32(16) : null;

describe("extractIcnsPng", () => {
it("picks the smallest PNG that covers the requested size", () => {
const file = icns(
["TOC ", new Uint8Array(16)],
["ic09", pngHeader(512)],
["icp5", pngHeader(32)],
["ic07", pngHeader(128)],
["ic08", pngHeader(256)],
);

expect(widthOf(extractIcnsPng(file, 96))).toBe(128);
});

it("falls back to the largest PNG when none covers the requested size", () => {
const file = icns(["icp4", pngHeader(16)], ["icp5", pngHeader(32)]);

expect(widthOf(extractIcnsPng(file, 96))).toBe(32);
});

it("returns null for files without a PNG entry or with a foreign header", () => {
expect(extractIcnsPng(icns(["it32", new Uint8Array(64)]), 96)).toBeNull();
expect(extractIcnsPng(pngHeader(128), 96)).toBeNull();
});

it("stops at an entry whose length runs past the file", () => {
const file = icns(["ic07", pngHeader(128)], ["ic08", pngHeader(256)]);
new DataView(file.buffer).setUint32(8 + 8 + 24 + 4, 4096);

expect(widthOf(extractIcnsPng(file, 200))).toBe(128);
});
});
30 changes: 30 additions & 0 deletions apps/server/src/assets/icns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { readImageDimensions } from "@t3tools/shared/imageDimensions";

const ICNS_HEADER_BYTES = 8;
const ICNS_MAGIC = 0x69636e73; // "icns"
const PNG_MAGIC = 0x89504e47; // "\x89PNG"

/**
* Picks the smallest PNG at least `minSize` wide, else the largest; null when there is none.
* Returns a copy so the response does not pin the whole `.icns` buffer.
*/
export function extractIcnsPng(bytes: Uint8Array, minSize: number): Uint8Array | null {
const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (bytes.length < ICNS_HEADER_BYTES || data.getUint32(0) !== ICNS_MAGIC) return null;
const end = Math.min(data.getUint32(4), bytes.length);
let best: { readonly png: Uint8Array; readonly width: number } | null = null;
for (let offset = ICNS_HEADER_BYTES; offset + ICNS_HEADER_BYTES <= end;) {
const length = data.getUint32(offset + 4);
if (length < ICNS_HEADER_BYTES || offset + length > end) break;
const entry = bytes.subarray(offset + ICNS_HEADER_BYTES, offset + length);
const isPng = entry.length >= 4 && data.getUint32(offset + ICNS_HEADER_BYTES) === PNG_MAGIC;
offset += length;
const width = isPng ? readImageDimensions(entry)?.width : undefined;
if (width === undefined) continue;
const isBetter =
best === null ||
(best.width < minSize ? width > best.width : width >= minSize && width < best.width);
if (isBetter) best = { png: entry, width };
}
return best?.png.slice() ?? null;
}
48 changes: 48 additions & 0 deletions apps/server/src/git/GitWorkflowService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import * as Option from "effect/Option";

import { VcsRepositoryDetectionError } from "@t3tools/contracts";

import * as NodeServices from "@effect/platform-node/NodeServices";
import * as FileSystem from "effect/FileSystem";

import * as ServerConfig from "../config.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as GitManager from "./GitManager.ts";
import * as GitWorkflowService from "./GitWorkflowService.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
Expand Down Expand Up @@ -220,4 +225,47 @@ describe("GitWorkflowService", () => {
),
);
});

it.effect("finds a name among the refs of every namespace", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-ref-named-" });
const workflow = yield* GitWorkflowService.GitWorkflowService;
const driver = yield* GitVcsDriver.GitVcsDriver;
for (const args of [
["init", "-b", "main"],
["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init"],
["update-ref", "refs/remotes/upstream/feature/topic", "HEAD"],
["symbolic-ref", "refs/remotes/origin/alias", "refs/heads/main"],
["update-ref", "refs/mirror/origin/mirrored", "HEAD"],
]) {
yield* driver.execute({ operation: "test.setupRepo", cwd, args });
}
const found = yield* Effect.forEach(
["main", "feature/topic", "alias", "mirrored", "t3/renamed", "feature/*"],
(refName) => workflow.hasRefNamed({ cwd, refName }),
);
assert.deepEqual(found, [true, true, true, true, false, false]);
}).pipe(
Effect.scoped,
Effect.provide(
GitWorkflowService.layer.pipe(
Layer.provide(
Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
resolve: () => Effect.succeed({ kind: "git" } as VcsDriverRegistry.VcsDriverHandle),
}),
),
Layer.provide(Layer.mock(GitManager.GitManager)({})),
Layer.provideMerge(GitVcsDriver.layer),
Layer.provideMerge(VcsProcess.layer),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-ref-named-" }).pipe(
Layer.provide(NodeServices.layer),
),
),
Layer.provideMerge(NodeServices.layer),
),
),
),
);
});
Loading
Loading