diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a03c7450f018..b87ad7979409 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -748,8 +748,9 @@ jobs: name: Publish GitHub Release needs: [preflight, build, publish_cli] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }} + # Blacksmith runners are not available to this fork. runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 30 permissions: contents: write steps: diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 1e15053c78ff..7ac29fce1d70 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -365,6 +365,7 @@ const config: ExpoConfig = { "./plugins/withAndroidModernPopupMenu.cjs", "./plugins/withAndroidModernAlertDialog.cjs", "./plugins/withAndroidPredictiveBackCompat.cjs", + "./plugins/withAndroidTabletOrientation.cjs", ...(isIosPersonalTeamBuild ? ["./plugins/withoutIosPersonalTeamCapabilities.cjs"] : []), ], extra: { diff --git a/apps/mobile/plugins/withAndroidTabletOrientation.cjs b/apps/mobile/plugins/withAndroidTabletOrientation.cjs new file mode 100644 index 000000000000..2254cdb1921e --- /dev/null +++ b/apps/mobile/plugins/withAndroidTabletOrientation.cjs @@ -0,0 +1,80 @@ +const { withMainActivity } = require("expo/config-plugins"); + +// The top-level `orientation: "portrait"` writes android:screenOrientation="portrait" +// into the manifest, which locks every Android device — including tablets — to +// portrait. iOS doesn't have this problem: iPads must support all orientations +// because the app is multitasking-capable, so only iPhones end up portrait-only. +// Mirror that split on Android: keep the manifest lock for phones and lift it at +// runtime on tablets (smallest width >= 600dp, the standard tablet breakpoint), +// since requestedOrientation set at runtime overrides the manifest value. +// FULL_USER allows all four orientations while still respecting the user's +// auto-rotate lock, matching iPad behavior. Foldables change +// smallestScreenWidthDp on fold/unfold without recreating the activity +// (smallestScreenSize is in the manifest's configChanges), so the policy is +// re-evaluated in onConfigurationChanged: unfolding past the tablet breakpoint +// unlocks rotation, and folding back restores the portrait lock. + +const ORIENTATION_METHODS = ` + // Applied in onCreate and re-applied on fold/unfold; added by + // withAndroidTabletOrientation. + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + applyTabletOrientation() + } + + private fun applyTabletOrientation() { + requestedOrientation = if (resources.configuration.smallestScreenWidthDp >= 600) { + ActivityInfo.SCREEN_ORIENTATION_FULL_USER + } else { + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + } +`; + +const ORIENTATION_ON_CREATE_CALL = ` + applyTabletOrientation()`; + +function insertAfter(contents, anchor, insertion, description) { + const index = contents.indexOf(anchor); + if (index === -1) { + throw new Error( + `withAndroidTabletOrientation: could not find ${description} in MainActivity — the Expo template changed; update the plugin anchors.`, + ); + } + const end = index + anchor.length; + return contents.slice(0, end) + insertion + contents.slice(end); +} + +module.exports = function withAndroidTabletOrientation(config) { + return withMainActivity(config, (nextConfig) => { + let contents = nextConfig.modResults.contents; + if (nextConfig.modResults.language !== "kt") { + throw new Error("withAndroidTabletOrientation: MainActivity must be Kotlin."); + } + if (contents.includes("SCREEN_ORIENTATION_FULL_USER")) { + return nextConfig; + } + + contents = insertAfter( + contents, + "import android.os.Bundle", + "\nimport android.content.pm.ActivityInfo\nimport android.content.res.Configuration", + "the android.os.Bundle import", + ); + contents = insertAfter( + contents, + "class MainActivity : ReactActivity() {", + ORIENTATION_METHODS, + "the MainActivity class declaration", + ); + contents = insertAfter( + contents, + "super.onCreate(null)", + ORIENTATION_ON_CREATE_CALL, + "the super.onCreate call", + ); + + nextConfig.modResults.contents = contents; + return nextConfig; + }); +}; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 7d5b408e04ce..4a80ec7bd568 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -17,7 +17,11 @@ import type { import * as Haptics from "expo-haptics"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Platform, StyleSheet, View, type GestureResponderEvent } from "react-native"; -import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; +import { + KeyboardController, + KeyboardStickyView, + useKeyboardState, +} from "react-native-keyboard-controller"; import Animated, { FadeInDown, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -194,7 +198,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const lastScrolledAnchorMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); const [anchorMessageId, setAnchorMessageId] = useState(null); - const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); + // Key the safe-area padding on keyboard visibility, not focus: on Android + // the back gesture closes the keyboard while the editor stays focused, and + // a focus-keyed inset would leave the toolbar under the gesture bar. + const isKeyboardVisible = useKeyboardState((state) => state.isVisible); + const composerBottomInset = isKeyboardVisible ? 0 : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; // The raw sync status enters "synchronizing" on every full fetch, cached or // not. Whether messages are already on screen decides the pill label: no diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 6208f04507e7..46577f377816 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -125,6 +125,31 @@ describe("parseModelsCliOutput", () => { NodeAssert.ok(model.variants); NodeAssert.equal(model.variants!["medium"] !== undefined, true); }); + + it("keeps a model whose JSON body has a slash and no interior whitespace", () => { + // OpenRouter-style: the model id contains a `/` and no string value has a + // space, so the JSON body line itself matches the slug regex. It must still + // be treated as the body of the preceding slug, not a new slug. + const stdout = [ + "openrouter/qwen/qwen3-coder", + JSON.stringify({ + id: "qwen/qwen3-coder", + providerID: "openrouter", + name: "qwen3-coder", + status: "active", + }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + NodeAssert.deepEqual([...result.connected], ["openrouter"]); + const provider = result.providers.get("openrouter")!; + NodeAssert.ok(provider); + const model = provider.models["qwen/qwen3-coder"]!; + NodeAssert.ok(model); + NodeAssert.equal(model.id, "qwen/qwen3-coder"); + NodeAssert.equal(model.providerID, "openrouter"); + }); }); describe("parseAgentListCliOutput", () => { diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 225229d24f36..182559a6f979 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -218,7 +218,13 @@ export function parseModelsCliOutput(stdout: string): { }; for (const line of lines) { - const slugMatch = SLUG_LINE_RE.exec(line); + // A model's JSON body is a single `JSON.stringify` line starting with `{`, + // while a provider/model slug is a bare `provider/model` header. Only the + // latter can be a slug: without this guard a body line with no interior + // whitespace and a `/` in one of its values (e.g. an OpenRouter model whose + // `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against + // an empty body and the model is silently dropped. + const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line); if (slugMatch) { flushModel(); currentSlug = slugMatch[1]!; diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts index 4c3afa97abfa..243556b6e3b0 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -4,7 +4,7 @@ import { HostProcessEnvironment, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; -import { assert, describe, it } from "@effect/vitest"; +import { afterEach, assert, describe, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -12,6 +12,36 @@ import { ServerConfig } from "../config.ts"; import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; describe("ResourceMonitorBinary", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.effect("skips Linux libc detection on Windows", () => + Effect.gen(function* () { + const getReport = vi.spyOn(process.report, "getReport").mockImplementation(() => { + throw new Error("Linux libc detection must not run on Windows"); + }); + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-resource-monitor-binary-", + }); + const binaryPath = `${baseDir}/t3-resource-monitor.exe`; + yield* fileSystem.writeFileString(binaryPath, "binary"); + + const service = yield* ResourceMonitorBinary.make().pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcessArchitecture, "arm64"), + Effect.provideService(HostProcessEnvironment, { + T3CODE_RESOURCE_MONITOR_PATH: binaryPath, + }), + ); + + assert.equal(yield* service.resolve, binaryPath); + expect(getReport).not.toHaveBeenCalled(); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolves an executable override", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts index 1f14df518660..c93bc54a1fba 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts @@ -106,7 +106,7 @@ export function resourceMonitorPlatformKey( export function resourceMonitorRustTarget( platform: NodeJS.Platform, architecture: NodeJS.Architecture, - linuxLibc: ResourceMonitorLinuxLibc, + linuxLibc?: ResourceMonitorLinuxLibc, ): string | undefined { if (platform === "darwin") { return architecture === "arm64" @@ -142,7 +142,7 @@ export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(fu const platform = yield* HostProcessPlatform; const architecture = yield* HostProcessArchitecture; const environment = yield* HostProcessEnvironment; - const linuxLibc = yield* ResourceMonitorHostLinuxLibc; + const linuxLibc = platform === "linux" ? yield* ResourceMonitorHostLinuxLibc : undefined; const executableName = binaryName(platform); const platformKey = resourceMonitorPlatformKey(platform, architecture); const rustTarget = resourceMonitorRustTarget(platform, architecture, linuxLibc); diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index ed87440d4996..7cf6a167ecfa 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -33,6 +33,7 @@ const testLayer = NodePtyAdapter.layer.pipe( it.effect("spawns through the public adapter with the provided host references", () => Effect.gen(function* () { + spawn.mockClear(); const adapter = yield* PtyAdapter.PtyAdapter; const process = yield* adapter.spawn({ shell: "powershell.exe", @@ -52,8 +53,35 @@ it.effect("spawns through the public adapter with the provided host references", cwd: "C:\\workspace", cols: 120, rows: 40, - env: {}, - name: "xterm-color", + env: { TERM: "xterm-256color" }, + name: "xterm-256color", + }, + ]); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("preserves a caller-provided TERM in the spawn env on win32", () => + Effect.gen(function* () { + spawn.mockClear(); + const adapter = yield* PtyAdapter.PtyAdapter; + yield* adapter.spawn({ + shell: "powershell.exe", + cwd: "C:\\workspace", + cols: 80, + rows: 24, + env: { TERM: "xterm-direct" }, + }); + + assert.equal(spawn.mock.calls.length, 1); + assert.deepEqual(spawn.mock.calls[0], [ + "powershell.exe", + [], + { + cwd: "C:\\workspace", + cols: 80, + rows: 24, + env: { TERM: "xterm-direct" }, + name: "xterm-256color", }, ]); }).pipe(Effect.provide(testLayer)), diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index ac06e1edfab8..e9c462ab2c20 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -141,14 +141,21 @@ export const make = Effect.fn("NodePtyAdapter.make")(function* ( return PtyAdapter.PtyAdapter.of({ spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) { yield* ensureNodePtySpawnHelperExecutableCached; + // node-pty only writes `name` into the child's TERM on the Unix path; + // the ConPTY path leaves the environment untouched, so Windows children + // inherit a missing or 16-color TERM unless it is set here. + const env = + platform === "win32" && input.env["TERM"] === undefined + ? { ...input.env, TERM: "xterm-256color" } + : input.env; const ptyProcess = yield* Effect.try({ try: () => nodePty.spawn(input.shell, input.args ?? [], { cwd: input.cwd, cols: input.cols, rows: input.rows, - env: input.env, - name: platform === "win32" ? "xterm-color" : "xterm-256color", + env, + name: "xterm-256color", }), catch: (cause) => new PtyAdapter.PtySpawnError({ diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index b7ee8e34f017..17dcd2491fad 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -956,6 +956,23 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports remote status on unborn HEAD without failing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + const initialBranch = yield* git(cwd, ["symbolic-ref", "--short", "HEAD"]); + + const status = yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); + + assert.equal(status.isRepo, true); + assert.equal(status.branch, initialBranch); + assert.equal(status.hasUpstream, false); + assert.equal(status.aheadCount, 0); + assert.equal(status.behindCount, 0); + }), + ); + it.effect("can read cached remote divergence without fetching upstream", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index b25b1423019a..b96442cf2d33 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1506,25 +1506,35 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (branchResult === null) { return NON_REPOSITORY_REMOTE_STATUS_DETAILS; } + let branch: string | null; if (branchResult.exitCode !== 0) { if (isNonRepositoryGitStderr(branchResult.stderr)) { return NON_REPOSITORY_REMOTE_STATUS_DETAILS; } - return yield* new GitCommandError({ - ...gitCommandContext({ - operation: "GitVcsDriver.statusDetailsRemote.branch", - cwd, - args: ["rev-parse", "--abbrev-ref", "HEAD"], - }), - detail: "Git branch lookup failed.", - exitCode: branchResult.exitCode, - stdoutLength: branchResult.stdout.length, - stderrLength: branchResult.stderr.length, - }); - } + if (!isUnbornHeadStderr(branchResult.stderr)) { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetailsRemote.branch", + cwd, + args: ["rev-parse", "--abbrev-ref", "HEAD"], + }), + detail: "Git branch lookup failed.", + exitCode: branchResult.exitCode, + stdoutLength: branchResult.stdout.length, + stderrLength: branchResult.stderr.length, + }); + } - const branchValue = branchResult.stdout.trim(); - const branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null; + const branchValue = yield* runGitStdout( + "GitVcsDriver.statusDetailsRemote.unbornBranch", + cwd, + ["symbolic-ref", "--quiet", "--short", "HEAD"], + ); + branch = branchValue.trim() || null; + } else { + const branchValue = branchResult.stdout.trim(); + branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null; + } const upstream = yield* resolveCurrentUpstream(cwd); const upstreamRef = upstream?.upstreamRef ?? null; let aheadCount = 0; diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts index bd10571427c9..16e17e4217eb 100644 --- a/apps/web/src/components/Sidebar.snooze.test.ts +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -8,15 +8,18 @@ function localDate(year: number, month: number, day: number, hour: number, minut } describe("resolveSnoozePresets", () => { - it("offers hour, evening, tomorrow, next week in the morning", () => { + it("offers one hour, three hours, evening, tomorrow, and next week in the morning", () => { // Wednesday 2026-04-08 10:00 local. const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale"); expect(presets.map((preset) => preset.id)).toEqual([ "hour", + "three-hours", "evening", "tomorrow", "next-week", ]); + const threeHours = presets.find((preset) => preset.id === "three-hours"); + expect(new Date(threeHours!.snoozedUntil).getHours()).toBe(13); const evening = presets.find((preset) => preset.id === "evening"); expect(new Date(evening!.snoozedUntil).getHours()).toBe(18); const tomorrow = presets.find((preset) => preset.id === "tomorrow"); @@ -45,10 +48,10 @@ describe("resolveSnoozePresets", () => { it("drops the evening preset once evening is near or past", () => { expect( resolveSnoozePresets(localDate(2026, 4, 8, 17, 30), "locale").map((preset) => preset.id), - ).toEqual(["hour", "tomorrow", "next-week"]); + ).toEqual(["hour", "three-hours", "tomorrow", "next-week"]); expect( resolveSnoozePresets(localDate(2026, 4, 8, 21), "locale").map((preset) => preset.id), - ).toEqual(["hour", "tomorrow", "next-week"]); + ).toEqual(["hour", "three-hours", "tomorrow", "next-week"]); }); it("puts next week a full week out when today is Monday", () => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ed9d91f55385..4e37866b3f41 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; import { DndContext, PointerSensor, @@ -251,6 +252,11 @@ const PROJECT_GROUPING_MODE_LABELS: Record = repository_path: "Group by repository path", separate: "Keep separate", }; +// Upstream's key, adopted verbatim so the two stay interchangeable. Its "v2" +// spelling is upstream's own legacy; the fork had no snoozed-shelf preference +// before this, so nothing is being migrated. The settled shelf keeps the +// fork's older key instead, which does hold existing preferences. +const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; return label.endsWith(" ago") ? label.slice(0, -4) : label; @@ -2279,8 +2285,15 @@ export default function Sidebar() { // The snoozed shelf is collapsed by default: out of the way, never gone. // Collapsed threads don't render (and so don't participate in jump // shortcuts or multi-select), matching the settled tail's paging model. - const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); - const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); + const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useLocalStorage( + SNOOZED_SHELF_EXPANDED_KEY, + false, + Schema.Boolean, + ); + const toggleSnoozedShelf = useCallback( + () => setSnoozedShelfExpanded((value) => !value), + [setSnoozedShelfExpanded], + ); const visibleSnoozedThreads = useMemo(() => { if (snoozedShelfExpanded) return snoozedThreads; // The open thread must never vanish behind the collapsed shelf: a diff --git a/apps/web/src/lib/imageCompression.ts b/apps/web/src/lib/imageCompression.ts index 1c57fdad1a55..be45024f38c4 100644 --- a/apps/web/src/lib/imageCompression.ts +++ b/apps/web/src/lib/imageCompression.ts @@ -140,27 +140,28 @@ function createCanvas(width: number, height: number): Canvas2D | null { * and it keeps alpha, so screenshots with transparency survive intact. * Browsers that can't encode it silently fall back to JPEG. */ -async function encodeToDataUrl( +async function encodeCanvas( canvas: OffscreenCanvas | HTMLCanvasElement, quality: number, mimeType: string, -): Promise<{ dataUrl: string; mimeType: string } | null> { + budgetChars: number, +): Promise<{ dataUrl: string | null; mimeType: string } | null> { if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) { const dataUrl = canvas.toDataURL(mimeType, quality); // toDataURL silently returns a PNG when the requested type is unsupported. if (!dataUrl.startsWith(`data:${mimeType}`)) return null; - return { dataUrl, mimeType }; + return { dataUrl: dataUrl.length <= budgetChars ? dataUrl : null, mimeType }; } const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality }); if (blob.type && blob.type !== mimeType) return null; + const dataUrlLength = `data:${mimeType};base64,`.length + 4 * Math.ceil(blob.size / 3); + if (dataUrlLength > budgetChars) return { dataUrl: null, mimeType }; return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType }; } /** * Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping - * quality down until the data URL fits `budgetChars`. Returns the smallest - * encoding produced, even if it still exceeds the budget, so the caller can - * decide whether to keep or drop it. + * quality down until the data URL fits `budgetChars`. */ async function encodeWithinBudget( bitmap: ImageBitmap, @@ -175,7 +176,7 @@ async function encodeWithinBudget( // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to // happen before drawing and depends on which codec we end up using. - const probe = await encodeToDataUrl(target.canvas, QUALITY_STEPS[0], "image/webp"); + const probe = await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0); const mimeType = probe ? "image/webp" : "image/jpeg"; if (mimeType === "image/jpeg") { @@ -184,18 +185,14 @@ async function encodeWithinBudget( } target.context.drawImage(bitmap, 0, 0, width, height); - let smallest: { dataUrl: string; mimeType: string } | null = null; for (const quality of QUALITY_STEPS) { - const encoded = await encodeToDataUrl(target.canvas, quality, mimeType); + const encoded = await encodeCanvas(target.canvas, quality, mimeType, budgetChars); if (!encoded) break; - if (smallest === null || encoded.dataUrl.length < smallest.dataUrl.length) { - smallest = encoded; - } - if (encoded.dataUrl.length <= budgetChars) { - return encoded; + if (encoded.dataUrl !== null) { + return { dataUrl: encoded.dataUrl, mimeType: encoded.mimeType }; } } - return smallest; + return null; } type ReencodeResult = diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 8631324d0a78..ecdc183b85bc 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1070,6 +1070,7 @@ function PullRequestsRouteView() { filtersMenu, rightPanelControl: !pullRequestsSupported || rightPanelState.isOpen ? null : panelToggleControls, + rightPanelOpen: rightPanelState.isOpen, listBody, }; @@ -1319,6 +1320,7 @@ function PullRequestsColumn({ searchInput, filtersMenu, rightPanelControl, + rightPanelOpen, listBody, }: { refreshing: boolean; @@ -1334,6 +1336,7 @@ function PullRequestsColumn({ searchInput: ReactNode; filtersMenu: ReactNode; rightPanelControl: ReactNode; + rightPanelOpen: boolean; listBody: ReactNode; }) { const scrollRef = useRef(null); @@ -1398,6 +1401,12 @@ function PullRequestsColumn({
diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 595b1303bea5..f8002d1c97b9 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -284,7 +284,7 @@ const HOUR_MS = 60 * 60 * 1_000; const EVENING_HOUR = 18; const MORNING_HOUR = 9; -export type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week"; +export type SnoozePresetId = "hour" | "three-hours" | "evening" | "tomorrow" | "next-week"; export interface SnoozePreset { readonly id: SnoozePresetId; @@ -317,11 +317,12 @@ function addSnoozeDays(base: Date, days: number): Date { /** * Shared "snooze until" choices for every client. "This evening" only - * appears while it is meaningfully before evening; after that the list - * starts at "Tomorrow". + * appears while it is meaningfully before evening; after that the calendar + * choices start at "Tomorrow". */ export function resolveSnoozePresets(now: Date): ReadonlyArray { const inAnHour = new Date(now.getTime() + HOUR_MS); + const inThreeHours = new Date(now.getTime() + 3 * HOUR_MS); const presets: SnoozePreset[] = [ { id: "hour", @@ -329,6 +330,12 @@ export function resolveSnoozePresets(now: Date): ReadonlyArray { whenLabel: snoozeTimeOfDayLabel(inAnHour), snoozedUntil: inAnHour.toISOString(), }, + { + id: "three-hours", + label: "In 3 hours", + whenLabel: snoozeTimeOfDayLabel(inThreeHours), + snoozedUntil: inThreeHours.toISOString(), + }, ]; const evening = snoozeAtHour(now, EVENING_HOUR); diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 2c1c58d37a1b..ff0c7d5d8e56 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -265,10 +265,15 @@ describe("resolveSnoozePresets", () => { const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10)); expect(presets.map((preset) => preset.id)).toEqual([ "hour", + "three-hours", "evening", "tomorrow", "next-week", ]); + expect(presets.find((preset) => preset.id === "three-hours")?.snoozedUntil).toBe( + localDate(2026, 4, 8, 13).toISOString(), + ); + expect(presets.find((preset) => preset.id === "three-hours")?.label).toBe("In 3 hours"); expect(presets.find((preset) => preset.id === "evening")?.label).toBe("This evening"); expect( new Date(presets.find((preset) => preset.id === "tomorrow")!.snoozedUntil).getHours(), @@ -278,6 +283,7 @@ describe("resolveSnoozePresets", () => { it("drops the evening choice once evening is near or past", () => { expect(resolveSnoozePresets(localDate(2026, 4, 8, 17, 30)).map((preset) => preset.id)).toEqual([ "hour", + "three-hours", "tomorrow", "next-week", ]);