Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a3c4e9e
fix(gitlab): merge request details report their head commit (#18608)
ScottN-PV Oct 12, 2026
ffb4af8
feat(server): rename built-in models from settings (#18840)
t3dotgg Oct 12, 2026
b03534f
fix(web): Git actions follow the selected thread (#18862)
Yash-Singh1 Oct 13, 2026
979589d
fix(desktop): preserve updater failure causes in diagnostics (#18864)
Yash-Singh1 Oct 13, 2026
34f8458
fix(web): attach dropped threads to pending answers (#18789)
Yash-Singh1 Oct 13, 2026
0179552
fix(web): keep button text stable during press scaling (#18866)
Yash-Singh1 Oct 13, 2026
8110edf
fix(bitbucket): preserve PR head commits for review guides (#18402)
ahilles107 Oct 13, 2026
e2e95a3
fix(server): return null for unresolved optional commands (#18871)
Yash-Singh1 Oct 13, 2026
5d6e419
fix: show computer-use action titles and align app icons (#18865)
Yash-Singh1 Oct 13, 2026
265fb70
fix(server): apply confirmed Claude subagent outcomes (#18778)
Yash-Singh1 Oct 13, 2026
41d86c1
fix(mobile): Browser page no longer logs a duplicate key warning (#18…
tris203 Oct 13, 2026
df26ef9
fix(web): a numeric q in the pull requests URL no longer crashes the …
ScottN-PV Oct 13, 2026
a143fa3
fix(client): T3 Connect clients reconnect once a new tunnel is ready …
shivamhwp Oct 13, 2026
346247b
fix(web): leaving the home page while the app loads no longer bounces…
shivamhwp Oct 13, 2026
1af1386
fix(mobile): attachment images no longer re-download on every reconne…
shivamhwp Oct 13, 2026
e24ea1d
fix(server): a migration id clash stops startup with a clear error in…
shivamhwp Oct 13, 2026
5a65169
fix(server): a thread with an event type from a newer build can still…
shivamhwp Oct 13, 2026
dc19285
fix(server): Codex titles and branch names no longer start the user's…
brycedbjork Oct 13, 2026
06620fa
fix(server): Claude thinking before a tool call stays a collapsed Tho…
maria-rcks Oct 13, 2026
ae7bed6
perf(server): cached branch PR lookups no longer start git processes …
SunkenInTime Oct 13, 2026
80664d5
fix(web): compact merge buttons show when they need a second click (#…
TonybynMp4 Oct 13, 2026
5070a83
fix(mobile): pull requests load from another environment when theirs …
juliusmarminge Oct 13, 2026
540a3e8
perf(web): collect active tool calls without array shifts (#18853)
saphid Oct 13, 2026
e9221e4
fix(web): center the pace icon on the limit card's "left" label (#18580)
akash3444 Oct 13, 2026
6abccc1
fix(web): update everything includes npm-installed servers again (#18…
ScottN-PV Oct 13, 2026
bbd98a5
fix(server): review guides find Bitbucket heads reported by short has…
ahilles107 Oct 13, 2026
e82a1ce
fix(web): scheduled task model picker no longer shifts the dialog (#1…
flamboh Oct 13, 2026
6524a8d
fix(ci): CodeRabbit reviews no longer request changes (#18954)
Yash-Singh1 Oct 13, 2026
7991b55
chore(sync): merge upstream/main into fork
adampeterhiggins Oct 13, 2026
07175cb
chore(sync): retain latest fork changes in upstream sync
adampeterhiggins Oct 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
5 changes: 2 additions & 3 deletions .coderabbit.config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { defineConfig, type CodeRabbitContext } from "@coderabbitai/config";

// Org members and collaborators merge their own pull requests. On anyone else's, CodeRabbit
// requests changes until its comments are resolved, then approves.
// Org members and collaborators can approve their own pull requests.
const UNGATED_AUTHORS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
const isGated = ({ pr }: CodeRabbitContext) => !UNGATED_AUTHORS.has(pr?.authorAssociation ?? "");

export default defineConfig((ctx) => ({
reviews: {
high_level_summary: false,
review_status: false,
request_changes_workflow: isGated(ctx),
request_changes_workflow: false,
allow_author_approval: !isGated(ctx),
auto_review: {
enabled: true,
Expand Down
115 changes: 112 additions & 3 deletions apps/desktop/src/updates/DesktopUpdates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,9 +430,10 @@ describe("DesktopUpdates", () => {
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});

it.effect("logs bounded updater failure context without exposing the cause", () => {
const cause = new Error(
"request failed for https://user:secret@example.com/update?token=secret",
it.effect("logs the redacted cause of a rejected update check", () => {
const cause = Object.assign(
new Error("request failed for https://user:secret@example.com/update?token=secret"),
{ code: "ERR_UPDATER_LATEST_VERSION_NOT_FOUND" },
);
const updaterError = new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({
channel: null,
Expand All @@ -459,6 +460,10 @@ describe("DesktopUpdates", () => {
assert.isDefined(loggedAnnotation);
assert.equal(loggedAnnotation.errorTag, "ElectronUpdaterCheckForUpdatesError");
assert.isNull(loggedAnnotation.channel);
assert.equal(
loggedAnnotation.cause,
"Error [ERR_UPDATER_LATEST_VERSION_NOT_FOUND]: request failed for [redacted URL]",
);
assert.notProperty(loggedAnnotation, "error");
assert.notInclude(Object.values(loggedAnnotation).map(String).join(" "), "secret");
assert.equal(
Expand All @@ -478,6 +483,49 @@ describe("DesktopUpdates", () => {
);
});

it.effect("logs the redacted cause of an updater error event", () => {
const harness = makeHarness();
const cause = Object.assign(new Error("404 Not Found\nHeaders: authorization=secret"), {
code: "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND",
});
const loggedAnnotations: Array<Record<string, unknown>> = [];
const logger = Logger.make(({ fiber }) => {
const annotations = fiber.getRef(References.CurrentLogAnnotations);
if (annotations.errorTag === "DesktopUpdaterReportedError") {
loggedAnnotations.push(annotations);
}
});

return Effect.scoped(
Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* updates.configure;

harness.emit("error", cause);
yield* flushCallbacks;

const loggedAnnotation = loggedAnnotations.at(-1);
assert.isDefined(loggedAnnotation);
assert.equal(loggedAnnotation.operation, "background");
assert.equal(
loggedAnnotation.cause,
"Error [ERR_UPDATER_CHANNEL_FILE_NOT_FOUND]: 404 Not Found",
);
assert.notInclude(JSON.stringify(loggedAnnotations), "secret");
const state = yield* updates.getState;
assert.equal(state.message, "Desktop updater background operation reported an error.");
}),
).pipe(
Effect.provide(
Layer.mergeAll(
TestClock.layer(),
harness.layer,
Logger.layer([logger], { mergeWithExisting: false }),
),
),
);
});

it.effect("recovers download state after an unexpected setup failure", () => {
let disableDifferentialCalls = 0;
const harness = makeHarness({
Expand Down Expand Up @@ -879,3 +927,64 @@ describe("DesktopUpdates", () => {
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});
});

describe("describeUpdateErrorCause", () => {
it("preserves network and nested failure reasons", () => {
const networkError = Object.assign(new Error("getaddrinfo ENOTFOUND updates.example.com"), {
code: "ENOTFOUND",
});
const cause = new Error("fetch failed", { cause: networkError });

assert.equal(
DesktopUpdates.describeUpdateErrorCause(Cause.fail(cause)),
"Error: fetch failed; caused by Error [ENOTFOUND]: getaddrinfo ENOTFOUND updates.example.com",
);
assert.include(DesktopUpdates.describeUpdateErrorCause(Cause.die(networkError)), "[ENOTFOUND]");
});

it("removes URLs, credentials, response bodies, and headers from summaries", () => {
const messages = [
"Cannot download https://user:url-secret@example.com/update?token=query-secret, status 403",
"Request failed: Bearer bearer-secret",
"Request failed: Basic basic-secret",
"Request failed: Authorization: Bearer header-secret",
"Request failed: Cookie=session-secret",
"Request failed: api_key=\"key-secret\" password='password-secret' access_token=token-secret",
'404 Not Found\n{"body":"body-secret"}\nHeaders: cookie=header-secret',
"Request failed\rHeaders: cookie=header-secret",
];

for (const message of messages) {
const summary = DesktopUpdates.describeUpdateErrorCause(new Error(message));
assert.include(summary, "Error:");
assert.notInclude(summary, "secret");
assert.notInclude(summary, "https://");
assert.notInclude(summary, "Headers:");
}
assert.equal(
DesktopUpdates.describeUpdateErrorCause("404 Not Found\nbody-secret"),
"404 Not Found",
);
});

it("bounds long messages, parallel failures, and cyclic causes", () => {
const longError = new Error("x".repeat(10_000));
const parallel = Cause.combine(Cause.fail(longError), Cause.die(longError));
assert.isAtMost(DesktopUpdates.describeUpdateErrorCause(parallel).length, 1024);

const cyclic = new Error("retry failed");
cyclic.cause = cyclic;
assert.include(DesktopUpdates.describeUpdateErrorCause(cyclic), "[truncated cause]");
assert.isAtMost(DesktopUpdates.describeUpdateErrorCause(cyclic).length, 1024);
});

it("does not serialize arbitrary payloads or let unreadable errors break logging", () => {
assert.equal(DesktopUpdates.describeUpdateErrorCause({ payload: "secret" }), "object");
const unreadable = Object.defineProperty({}, "message", {
get() {
throw new Error("unreadable error");
},
});
assert.equal(DesktopUpdates.describeUpdateErrorCause(unreadable), "UnknownError");
});
});
70 changes: 70 additions & 0 deletions apps/desktop/src/updates/DesktopUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type DesktopUpdateCheckResult,
type DesktopUpdateState,
} from "@t3tools/contracts";
import { errorTag } from "@t3tools/shared/observability";
import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
Expand Down Expand Up @@ -196,6 +197,63 @@ const {
logError: logUpdaterError,
} = DesktopObservability.makeComponentLogger("desktop-updater");

function redactMessage(message: string): string {
// Updater HTTP errors append response bodies and headers after the first line.
return (message.split(/[\r\n]/, 1)[0] ?? "")
.replace(/\b(?:https?|file):\/\/[^\s"'<>]+/gi, "[redacted URL]")
.replace(/\b(?:Bearer|Basic)\s+[^\s,;]+/gi, "[redacted credential]")
.replace(
/\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*[:=].*$/gi,
"[redacted header]",
)
.replace(
/\b[\w-]*(?:token|password|secret|credential|api[_-]?key)[\w-]*\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi,
"[redacted credential]",
)
.slice(0, 512);
}

function summarizeCause(cause: unknown, depth: number): string {
if (depth >= 3) return "[truncated cause]";
if (Cause.isCause(cause)) {
return cause.reasons
.slice(0, 3)
.map((reason) =>
reason._tag === "Fail"
? summarizeCause(reason.error, depth + 1)
: reason._tag === "Die"
? summarizeCause(reason.defect, depth + 1)
: "Interrupted",
)
.join("; ");
}
if (typeof cause === "string") return redactMessage(cause);
if (typeof cause !== "object" || cause === null) return errorTag(cause);

const error = cause as {
readonly message?: unknown;
readonly code?: unknown;
readonly cause?: unknown;
};
const code =
typeof error.code === "string" && /^[A-Z][A-Z0-9_]{0,127}$/.test(error.code)
? ` [${error.code}]`
: "";
const message = typeof error.message === "string" ? `: ${redactMessage(error.message)}` : "";
const nested =
error.cause === undefined ? "" : `; caused by ${summarizeCause(error.cause, depth + 1)}`;
return `${errorTag(cause)}${code}${message}${nested}`;
}

/** Log a bounded cause summary without stacks, response payloads, or request credentials. */
export function describeUpdateErrorCause(cause: unknown): string {
try {
return summarizeCause(cause, 0).slice(0, 1024);
} catch {
return "UnknownError";
}
}

function parseAppUpdateYml(raw: string): Effect.Effect<Option.Option<AppUpdateYmlConfig>> {
const entries: Record<string, string> = {};
for (const line of raw.split("\n")) {
Expand Down Expand Up @@ -442,6 +500,7 @@ export const make = Effect.gen(function* () {
);
yield* logUpdaterError(error.message, {
errorTag: error._tag,
cause: describeUpdateErrorCause(error.cause),
channel: error.channel,
});
return true;
Expand Down Expand Up @@ -485,6 +544,7 @@ export const make = Effect.gen(function* () {
);
yield* logUpdaterError(error.message, {
errorTag: error._tag,
cause: describeUpdateErrorCause(error.cause),
channel: error.channel,
});
return { accepted: true, completed: false };
Expand All @@ -507,6 +567,7 @@ export const make = Effect.gen(function* () {
);
yield* logUpdaterError(error.message, {
errorTag: error._tag,
cause: describeUpdateErrorCause(error.cause),
action: error.action,
});
return { accepted: true, completed: false };
Expand Down Expand Up @@ -655,6 +716,7 @@ export const make = Effect.gen(function* () {
yield* recoverFailedInstall(error.message);
yield* logUpdaterError(error.message, {
errorTag: error._tag,
cause: describeUpdateErrorCause(error.cause),
channel: error.channel,
isSilent: error.isSilent,
isForceRunAfter: error.isForceRunAfter,
Expand All @@ -673,6 +735,7 @@ export const make = Effect.gen(function* () {
yield* recoverFailedInstall(error.message);
yield* logUpdaterError(error.message, {
errorTag: error._tag,
cause: describeUpdateErrorCause(error.cause),
action: error.action,
});
return { accepted: true, completed: false, failed: true };
Expand Down Expand Up @@ -712,6 +775,7 @@ export const make = Effect.gen(function* () {
const error = new DesktopUpdatePollerError({ poller: "startup", cause });
return logUpdaterError(error.message, {
errorTag: error._tag,
cause: describeUpdateErrorCause(error.cause),
poller: error.poller,
});
}),
Expand All @@ -727,6 +791,7 @@ export const make = Effect.gen(function* () {
const error = new DesktopUpdatePollerError({ poller: "poll", cause });
return logUpdaterError(error.message, {
errorTag: error._tag,
cause: describeUpdateErrorCause(error.cause),
poller: error.poller,
});
}),
Expand Down Expand Up @@ -782,6 +847,7 @@ export const make = Effect.gen(function* () {
const error = new DesktopUpdateEventHandlingError({ event: "update-available", cause });
return logUpdaterWarning(error.message, {
errorTag: error._tag,
cause: describeUpdateErrorCause(error.cause),
event: error.event,
});
}),
Expand Down Expand Up @@ -811,6 +877,7 @@ export const make = Effect.gen(function* () {
yield* recoverFailedInstall(error.message);
yield* logUpdaterError(error.message, {
errorTag: error._tag,
cause: describeUpdateErrorCause(error.cause),
operation: error.operation,
});
return;
Expand All @@ -831,6 +898,7 @@ export const make = Effect.gen(function* () {

yield* logUpdaterError(error.message, {
errorTag: error._tag,
cause: describeUpdateErrorCause(error.cause),
operation: error.operation,
});
});
Expand Down Expand Up @@ -861,6 +929,7 @@ export const make = Effect.gen(function* () {
const error = new DesktopUpdateEventHandlingError({ event: "download-progress", cause });
return logUpdaterWarning(error.message, {
errorTag: error._tag,
cause: describeUpdateErrorCause(error.cause),
event: error.event,
});
}),
Expand All @@ -885,6 +954,7 @@ export const make = Effect.gen(function* () {
const error = new DesktopUpdateEventHandlingError({ event: "update-downloaded", cause });
return logUpdaterWarning(error.message, {
errorTag: error._tag,
cause: describeUpdateErrorCause(error.cause),
event: error.event,
});
}),
Expand Down
33 changes: 33 additions & 0 deletions apps/mobile/src/components/BrowserAppIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ColorValue } from "react-native";
import Svg, { Circle, Path } from "react-native-svg";
import { withUniwind } from "uniwind";

const ThemedSvg = withUniwind(Svg);

/** Matches the window-and-pointer icon used by the web work log. */
export function BrowserAppIcon(props: {
readonly size: number;
readonly color?: ColorValue;
readonly colorClassName?: string;
}) {
return (
<ThemedSvg
width={props.size}
height={props.size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.9}
strokeLinecap="round"
strokeLinejoin="round"
color={props.color}
colorClassName={props.colorClassName}
accessible={false}
>
<Path d="M8.5 19H7.2C4.4 19 3 17.5 3 14.6V7.4C3 4.5 4.5 3 7.4 3h8.2C18.5 3 20 4.5 20 7.4v2.4" />
<Circle cx={7.4} cy={7.2} r={0.75} fill="currentColor" stroke="none" />
<Path d="M11.2 7.2h4.3" />
<Path d="m12.4 11.4 7.5 2.6-3.4 1.6-1.5 3.6z" fill="currentColor" stroke="none" />
</ThemedSvg>
);
}
7 changes: 6 additions & 1 deletion apps/mobile/src/components/ComposerContextAttachment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { DraftComposerAttachment } from "../lib/composerImages";
import { composerAttachmentInlineUri, isFileBackedComposerAttachment } from "../lib/composerImages";
import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
import { downloadAndShareAttachment } from "../lib/attachmentDownload";
import { assetImageSource } from "../lib/assetImageSource";
import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets";
import { AppText as Text } from "./AppText";
import { FilePreviewModal } from "./FilePreviewModal";
Expand Down Expand Up @@ -98,7 +99,11 @@ export function ComposerContextAttachment(props: {
onPress={() => setPreviewOpen(true)}
>
<Image
source={{ uri }}
source={
remoteEnvironmentId === null
? { uri }
: assetImageSource(remoteEnvironmentId, resource, uri)
}
style={{ width: "100%", height: 280 }}
contentFit="contain"
accessibilityLabel={record.name}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ function BrowserPreviewScreen({
{tab ? (
<>
<BrowserAddressBar
key={tab.tabId}
key={`address:${tab.tabId}`}
environmentId={environmentId}
tab={tab}
streaming={streaming}
Expand All @@ -178,7 +178,7 @@ function BrowserPreviewScreen({
/>
{live ? (
<PreviewStreamWebView
key={tab.tabId}
key={`stream:${tab.tabId}`}
environmentId={environmentId}
threadId={threadId}
tabId={tab.tabId}
Expand Down
Loading