diff --git a/.github/workflows/stale-needs-more-info.yml b/.github/workflows/stale-needs-more-info.yml new file mode 100644 index 000000000000..b7b475a6117d --- /dev/null +++ b/.github/workflows/stale-needs-more-info.yml @@ -0,0 +1,30 @@ +name: Close stale "needs more info" issues + +on: + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + actions: write + issues: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + only-issue-labels: "needs more info" + # The label is applied by the triage flow, so never auto-mark; close 7 days after it was added. + days-before-issue-stale: -1 + days-before-issue-close: 7 + days-before-pr-stale: -1 + days-before-pr-close: -1 + remove-issue-stale-when-updated: true + stale-issue-label: "needs more info" + close-issue-reason: not_planned + close-issue-message: >- + Closing: no reply in 7 days after we asked for more information. + Comment with the missing details and we will reopen. + operations-per-run: 200 diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index f9f38dea3dd4..6a6e991fc6b4 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -3,6 +3,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; @@ -18,6 +19,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; function layerElectronApp( appListeners: Map void>, quit: Effect.Effect = Effect.void, + exit: (code: number) => Effect.Effect = () => Effect.void, ) { const registerListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => Effect.acquireRelease( @@ -37,7 +39,7 @@ function layerElectronApp( whenReady: Effect.void, requestSingleInstanceLock: Effect.succeed(true), quit, - exit: () => Effect.void, + exit, relaunch: () => Effect.void, setPath: () => Effect.void, setName: () => Effect.void, @@ -221,6 +223,60 @@ describe("DesktopLifecycle", () => { }), ); + it.effect("exits on a repeated quit only once shutdown has stalled", () => + Effect.gen(function* () { + const appListeners = new Map void>(); + const exitCodes: number[] = []; + const exit = (code: number) => + Effect.sync(() => { + exitCodes.push(code); + }); + const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "darwin", + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + const layer = DesktopLifecycle.layer.pipe( + Layer.provideMerge(layerElectronApp(appListeners, Effect.void, exit)), + Layer.provideMerge(layerElectronTheme), + Layer.provideMerge(layerElectronWindow()), + Layer.provideMerge(layerDesktopWindow()), + Layer.provideMerge(layerEnvironment), + // Nothing marks this shutdown complete, so it stays pending. + Layer.provideMerge(DesktopShutdown.layer), + Layer.provideMerge(DesktopState.layer), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + let prevented = 0; + const event = { + preventDefault: () => { + prevented += 1; + }, + } as Electron.Event; + appListeners.get("before-quit")?.(event); + appListeners.get("before-quit")?.(event); + yield* TestClock.adjust("9999 millis"); + appListeners.get("before-quit")?.(event); + assert.deepEqual( + exitCodes, + [], + "a repeat before the threshold must not cut shutdown short", + ); + + yield* TestClock.adjust("1 millis"); + appListeners.get("before-quit")?.(event); + + assert.deepEqual(exitCodes, [0]); + assert.equal(prevented, 4); + }), + ).pipe(Effect.provide(layer)); + }), + ); + it.effect("ignores app activation while quitting", () => Effect.gen(function* () { const appListeners = new Map void>(); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 924341781aa6..7512d2bc1741 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -1,4 +1,6 @@ +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; @@ -94,15 +96,50 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo }, ); +interface QuitState { + phase: "running" | "waiting" | "allowed"; + waitingSinceNanos: bigint; +} + +// Longer than a backend's stop budget (SIGTERM, 5s grace, SIGKILL, 1s wait), +// so a double Cmd-Q at the start of a normal quit cannot cut the backend stop +// short and orphan it. +const SHUTDOWN_STALL_THRESHOLD = Duration.seconds(10); + +const startWaitingForShutdown = Effect.fn("desktop.lifecycle.startWaitingForShutdown")(function* ( + quit: QuitState, +) { + quit.phase = "waiting"; + quit.waitingSinceNanos = yield* Clock.monotonicTimeNanos; +}); + +// While a quit waits on shutdown, `quitting` makes activation open nothing. If +// that shutdown stalls, a repeated Quit or SIGTERM is the user's only way out +// short of SIGKILL. +const exitIfShutdownStalled = Effect.fn("desktop.lifecycle.exitIfShutdownStalled")(function* ( + quit: QuitState, + source: string, +) { + const pendingMs = Number( + ((yield* Clock.monotonicTimeNanos) - quit.waitingSinceNanos) / 1_000_000n, + ); + if (pendingMs < Duration.toMillis(SHUTDOWN_STALL_THRESHOLD)) { + yield* logLifecycleInfo("quit already in progress", { source, pendingMs }); + return; + } + yield* logLifecycleError("desktop shutdown stalled; exiting without it", { source, pendingMs }); + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.exit(0); +}); + function handleBeforeQuit( event: Electron.Event, runEffect: ( effect: Effect.Effect, ) => Promise, - allowQuit: () => boolean, - markQuitAllowed: () => void, + quit: QuitState, ): void { - if (allowQuit()) { + if (quit.phase === "allowed") { void runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; @@ -114,10 +151,15 @@ function handleBeforeQuit( } event.preventDefault(); + if (quit.phase === "waiting") { + void runEffect(exitIfShutdownStalled(quit, "before-quit")); + return; + } void runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; const electronWindow = yield* ElectronWindow.ElectronWindow; + yield* startWaitingForShutdown(quit); yield* Ref.set(state.quitting, true); yield* logLifecycleInfo("before-quit received"); yield* requestDesktopShutdownAndWait( @@ -129,7 +171,7 @@ function handleBeforeQuit( ); }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), ).finally(() => { - markQuitAllowed(); + quit.phase = "allowed"; void runEffect( Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; @@ -144,7 +186,12 @@ function quitFromSignal( runEffect: ( effect: Effect.Effect, ) => Promise, + quit: QuitState, ): void { + if (quit.phase === "waiting") { + void runEffect(exitIfShutdownStalled(quit, signal)); + return; + } void runEffect( Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ signal }); @@ -152,8 +199,10 @@ function quitFromSignal( const state = yield* DesktopState.DesktopState; const wasQuitting = yield* Ref.getAndSet(state.quitting, true); if (wasQuitting) return; + yield* startWaitingForShutdown(quit); yield* logLifecycleInfo("process signal received", { signal }); yield* requestDesktopShutdownAndWait(); + quit.phase = "allowed"; yield* electronApp.quit; }).pipe(Effect.withSpan("desktop.lifecycle.processSignal")), ); @@ -196,8 +245,7 @@ export const make = DesktopLifecycle.of({ const environment = yield* DesktopEnvironment.DesktopEnvironment; const context = yield* Effect.context(); const runEffect = Effect.runPromiseWith(context); - let quitAllowed = false; - let updaterQuitAllowed = false; + const quit: QuitState = { phase: "running", waitingSinceNanos: 0n }; yield* electronTheme.onUpdated(() => { void runEffect( desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), @@ -207,7 +255,7 @@ export const make = DesktopLifecycle.of({ // Electron's updater owns the remaining quit/install/relaunch sequence. // Cancelling the following app "before-quit" event breaks that sequence, // most visibly on macOS where the native updater performs the relaunch. - updaterQuitAllowed = true; + quit.phase = "allowed"; // This event is synchronous and the updater's quit proceeds as soon as // the listener returns, so a forked destroyAll would race the quit // and windows could still be open when the process exits (visible on @@ -223,14 +271,7 @@ export const make = DesktopLifecycle.of({ ); }); yield* electronApp.on("before-quit", (event: Electron.Event) => { - handleBeforeQuit( - event, - runEffect, - () => quitAllowed || updaterQuitAllowed, - () => { - quitAllowed = true; - }, - ); + handleBeforeQuit(event, runEffect, quit); }); yield* electronApp.on("activate", () => { void runEffect( @@ -255,10 +296,10 @@ export const make = DesktopLifecycle.of({ if (environment.platform !== "win32") { yield* addScopedListener(process, "SIGINT", () => { - quitFromSignal("SIGINT", runEffect); + quitFromSignal("SIGINT", runEffect, quit); }); yield* addScopedListener(process, "SIGTERM", () => { - quitFromSignal("SIGTERM", runEffect); + quitFromSignal("SIGTERM", runEffect, quit); }); } }).pipe(Effect.withSpan("desktop.lifecycle.register")), diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 4dc5b47c4e59..34270c383f31 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -1,4 +1,5 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import desktopPackageJson from "../../package.json" with { type: "json" }; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -356,4 +357,33 @@ describe("DesktopObservability", () => { ), ); }); + + it.effect("reports the app's own version for an unpackaged desktop run", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const layerEnvironment = layerEnvironmentFor(baseDir); + yield* writeObservabilitySettings(layerEnvironment, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop resource under test").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), + ), + ); + + const body = requests.map((request) => request.body).join("\n"); + assert.include( + body, + `"key":"service.version","value":{"stringValue":"${desktopPackageJson.version}"}`, + ); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), + ); + }); }); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 04dcd22cf3b2..50039c4e4b04 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { DesktopBrowserEvent } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as NodeEvents from "node:events"; @@ -15,32 +16,54 @@ const decodeCdpReply = Schema.decodeUnknownSync( Schema.fromJsonString(Schema.Struct({ id: Schema.Number })), ); const key = { threadId: "thread-1", tabId: "tab-1" }; +/** Sends a page command whose id the fake debugger records as it receives it. */ +const command = ( + host: DesktopBrowserHost.DesktopBrowserHost["Service"], + id: number, + method: string, +) => + host.handleCommandLine( + encodeJson({ + type: "cdp", + ...key, + message: encodeJson({ id, method, params: { id }, sessionId: "t3-preview-page" }), + }), + ); const makeDebuggee = () => { const emitter = new NodeEvents.EventEmitter(); const pending: Array<() => void> = []; + const sent: Array = []; + const sendWaiters: Array<() => void> = []; const debuggee = Object.assign(emitter, { - sendCommand: (method: string) => + sendCommand: (method: string, params?: { id?: number }) => new Promise((resolve) => { if (method === "Target.getTargetInfo") { resolve({ targetInfo: { targetId: "GUEST" } }); return; } + sent.push(params?.id); pending.push(() => resolve({ method })); + sendWaiters.shift()?.(); }), }); - const webContents = { + const webContents = Object.assign(new NodeEvents.EventEmitter(), { getURL: () => "http://localhost/", getTitle: () => "Page", getUserAgent: () => "Electron", - }; + }); return { tab: { webContents: webContents as unknown as Electron.WebContents, debugger: debuggee as unknown as Electron.Debugger, }, emit: (method: string, params: unknown) => emitter.emit("message", {}, method, params, ""), + navigate: () => webContents.emit("did-navigate"), + /** Ids of the commands the debugger received, in order. */ + sent, + nextSend: () => new Promise((resolve) => sendWaiters.push(resolve)), release: () => pending.splice(0).forEach((resolve) => resolve()), + releaseFirst: () => pending.shift()?.(), }; }; @@ -51,6 +74,25 @@ const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], coun Effect.map((lines) => lines.map((line) => decodeEvent(new TextDecoder().decode(line)))), ); +/** + * Ids of the replies the host sends, in order. A screenshot's reply leaves after its turn + * has passed on, so by then the next waiting screenshot, if any, has been sent. Subscribe + * before sending commands: a new subscriber releases the tab's relay. + */ +const watchReplies = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) => + Effect.gen(function* () { + const replies = yield* Queue.unbounded(); + yield* host.events.pipe( + Stream.map((line) => decodeEvent(new TextDecoder().decode(line))), + Stream.runForEach((event) => + event.type === "cdp" ? Queue.offer(replies, decodeCdpReply(event.message).id) : Effect.void, + ), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + return replies; + }); + describe("DesktopBrowserHost", () => { it.effect("announces tabs already attached to a backend that starts later", () => Effect.gen(function* () { @@ -89,6 +131,67 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); + it.effect("sends a page one screenshot at a time", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* command(host, 3, "DOM.enable"); + expect(debuggee.sent).toEqual([1, 3]); + + debuggee.releaseFirst(); + expect(yield* Queue.take(replies)).toBe(1); + expect(debuggee.sent).toEqual([1, 3, 2]); + }).pipe(Effect.scoped), + ); + + it.effect("stops waiting on a screenshot the page navigated away from", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* command(host, 3, "Page.captureScreenshot"); + debuggee.navigate(); + expect(debuggee.sent).toEqual([1, 2]); + + // The superseded screenshot answering late must not start a third beside the second. + debuggee.releaseFirst(); + expect(yield* Queue.take(replies)).toBe(1); + expect(debuggee.sent).toEqual([1, 2]); + + debuggee.releaseFirst(); + expect(yield* Queue.take(replies)).toBe(2); + expect(debuggee.sent).toEqual([1, 2, 3]); + }).pipe(Effect.scoped), + ); + + it.effect("drops screenshots a released relay left waiting", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); + yield* command(host, 3, "Page.captureScreenshot"); + expect(debuggee.sent).toEqual([1]); + + const sent = debuggee.nextSend(); + debuggee.release(); + yield* Effect.promise(() => sent); + expect(debuggee.sent).toEqual([1, 3]); + }), + ); + it.effect("saves a server tab's download under its CDP guid where the server asked", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index cef42d4f35fc..7028d16b4be2 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -43,6 +43,8 @@ interface AttachedTab { readonly key: DesktopBrowserTabKey; readonly debuggee: DesktopBrowserTabDebugger; relay: CdpRelayConnection | null; + screenshotTurn: object | null; + readonly waitingScreenshots: Array<() => void>; downloadDirectory: string | null; pendingDownloadGuid: string | null; agentInputAt: number; @@ -52,6 +54,7 @@ interface AttachedTab { params: unknown, sessionId: string, ) => void; + readonly onNewDocument: () => void; } export class DesktopBrowserHost extends Context.Service< @@ -84,15 +87,54 @@ export const make = Effect.gen(function* () { const tabs = new Map(); const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event)); + const nextScreenshot = (tab: AttachedTab) => { + tab.screenshotTurn = null; + tab.waitingScreenshots.shift()?.(); + }; + + /** + * Chromium below 157 crashes the whole app when a guest's renderer exits with a + * screenshot copy in flight (crbug.com/567321049). Overlapping screenshots make that + * far likelier, and one the server gave up on stays pending in Chromium, so each guest + * gets one at a time. One queued by a relay released since then is dropped unsent. + */ + const takeScreenshot = ( + tab: AttachedTab, + relay: CdpRelayConnection, + capture: () => Promise, + ) => + new Promise((resolve, reject) => { + const start = () => { + if (tab.relay !== relay) { + reject(new Error("The preview connection that asked for this screenshot closed.")); + nextScreenshot(tab); + return; + } + const turn = {}; + tab.screenshotTurn = turn; + capture() + .finally(() => { + if (tab.screenshotTurn === turn) nextScreenshot(tab); + }) + .then(resolve, reject); + }; + if (tab.screenshotTurn === null) start(); + else tab.waitingScreenshots.push(start); + }); + const relayFor = (tab: AttachedTab) => { if (tab.relay) return tab.relay; const { webContents, debugger: debuggee } = tab.debuggee; + const send = (method: string, params: Record, sessionId?: string) => + sessionId === undefined + ? sendPageCommand(webContents, debuggee, method, params) + : debuggee.sendCommand(method, params, sessionId); const relay: CdpRelayConnection = createCdpRelayConnection( { send: (method, params, sessionId) => - sessionId === undefined - ? sendPageCommand(webContents, debuggee, method, params) - : debuggee.sendCommand(method, params, sessionId), + method === "Page.captureScreenshot" + ? takeScreenshot(tab, relay, () => send(method, params, sessionId)) + : send(method, params, sessionId), targetId: () => debuggee .sendCommand("Target.getTargetInfo") @@ -133,7 +175,9 @@ export const make = Effect.gen(function* () { const tab = tabs.get(id); if (!tab) return; tabs.delete(id); + tab.relay = null; tab.debuggee.debugger.off("message", tab.onMessage); + tab.debuggee.webContents.off("did-navigate", tab.onNewDocument); emit({ type: "detached", ...key }); }; @@ -145,6 +189,8 @@ export const make = Effect.gen(function* () { key, debuggee, relay: null, + screenshotTurn: null, + waitingScreenshots: [], downloadDirectory: null, pendingDownloadGuid: null, agentInputAt: Number.NEGATIVE_INFINITY, @@ -155,9 +201,15 @@ export const make = Effect.gen(function* () { } tab.relay?.event(method, params, sessionId); }, + // A screenshot pending when the page loads a new document, a reload included, can + // stay unanswered. A crashed page lands here once it reloads, well after its renderer died. + onNewDocument: () => { + if (tab.screenshotTurn !== null) nextScreenshot(tab); + }, }; tabs.set(id, tab); debuggee.debugger.on("message", tab.onMessage); + debuggee.webContents.on("did-navigate", tab.onNewDocument); emit({ type: "attached", ...key }); }; diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index e49d27d40ba9..4059468da22e 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -35,6 +35,7 @@ import { START_PICK_CHANNEL, } from "./GuestProtocol.ts"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; +const INPUT_LAYER_ATTRIBUTE = "data-t3code-annotation-input"; const Z_INDEX_OVERLAY = 2147483646; const PRIMARY = "var(--t3-primary)"; const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; @@ -268,7 +269,7 @@ function isAnnotationNode(element: Element): boolean { function pickFromPoint(clientX: number, clientY: number): Element | null { for (const candidate of document.elementsFromPoint(clientX, clientY)) { if (!(candidate instanceof Element)) continue; - if (isAnnotationNode(candidate)) continue; + if (isAnnotationNode(candidate) || candidate.hasAttribute(INPUT_LAYER_ATTRIBUTE)) continue; if (candidate === document.documentElement || candidate === document.body) continue; return candidate; } @@ -636,7 +637,18 @@ function startAnnotation(sendEnabled: boolean): void { const strokes: PreviewAnnotationStrokeTarget[] = []; const styleChanges = new Map(); const toolButtons = new Map(); - let tool: AnnotationTool = "select"; + // Chromium draws a PDF in its own viewer frame, so pointer and key input over it + // never reaches this document. A transparent layer above the viewer catches the + // pointer and takes focus for Escape and the tool keys. A PDF has no elements to + // select, so it starts on Region. + const isPdf = document.contentType === "application/pdf"; + const inputLayer = isPdf ? document.createElement("div") : null; + if (inputLayer) { + inputLayer.setAttribute(INPUT_LAYER_ATTRIBUTE, ""); + inputLayer.tabIndex = -1; + inputLayer.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY - 1};cursor:crosshair;outline:none`; + } + let tool: AnnotationTool = isPdf ? "marquee" : "select"; let dragStart: PreviewAnnotationPoint | null = null; let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; let pendingCapture = false; @@ -1356,6 +1368,7 @@ function startAnnotation(sendEnabled: boolean): void { ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); document.documentElement.removeAttribute("data-t3code-annotation-tool"); cursorStyle.remove(); + inputLayer?.remove(); host.remove(); activeSession = null; if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); @@ -1467,6 +1480,10 @@ function startAnnotation(sendEnabled: boolean): void { window.addEventListener("resize", repaint, { passive: true }); ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + if (inputLayer) { + document.documentElement.appendChild(inputLayer); + inputLayer.focus({ preventScroll: true }); + } document.documentElement.appendChild(host); rootResizeObserver.observe(document.documentElement); refreshToolButtons(); diff --git a/apps/desktop/src/updates/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts index 41af75f61e96..03e864b93d27 100644 --- a/apps/desktop/src/updates/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -8,7 +8,6 @@ import { reduceDesktopUpdateStateOnDownloadFailure, reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, - reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine.ts"; const runtimeInfo = { @@ -50,30 +49,6 @@ describe("updateMachine", () => { expect(failed.message).toBeNull(); }); - it("keeps the installer when the feed still offers its version", () => { - const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"], totalItems: 1 }]; - const state = reduceDesktopUpdateStateOnUpdateAvailable( - { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), - enabled: true, - status: "downloaded", - availableVersion: "1.1.0", - downloadedVersion: "1.1.0", - releaseNotes, - omittedReleaseCount: 2, - downloadPercent: 100, - }, - "1.1.0", - "2026-03-04T00:00:00.000Z", - ); - - expect(state.status).toBe("downloaded"); - expect(state.downloadedVersion).toBe("1.1.0"); - expect(state.releaseNotes).toEqual(releaseNotes); - expect(state.omittedReleaseCount).toBe(2); - expect(state.downloadPercent).toBe(100); - }); - it("preserves available version on download failure for retry", () => { const state = reduceDesktopUpdateStateOnDownloadFailure( { @@ -114,35 +89,6 @@ describe("updateMachine", () => { expect(failedInstall.canRetry).toBe(true); }); - it("preserves a downloaded update when no update is available", () => { - const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"], totalItems: 1 }]; - const state = reduceDesktopUpdateStateOnNoUpdate( - { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), - enabled: true, - status: "error", - availableVersion: "1.1.0", - downloadedVersion: "1.1.0", - releaseNotes, - omittedReleaseCount: 2, - message: "old failure", - errorContext: "download", - canRetry: true, - }, - "2026-03-04T00:00:00.000Z", - ); - - expect(state.status).toBe("downloaded"); - expect(state.availableVersion).toBe("1.1.0"); - expect(state.downloadedVersion).toBe("1.1.0"); - expect(state.releaseNotes).toBe(releaseNotes); - expect(state.omittedReleaseCount).toBe(2); - expect(state.downloadPercent).toBe(100); - expect(state.message).toBeNull(); - expect(state.errorContext).toBeNull(); - expect(state.canRetry).toBe(true); - }); - it("clears stale available state when no update is available", () => { const state = reduceDesktopUpdateStateOnNoUpdate( { diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.36.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.36.tgz deleted file mode 100644 index 071a4022a7b3..000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.36.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.37.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.37.tgz new file mode 100644 index 000000000000..0d0076c8a3c6 Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.37.tgz differ diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 87c9d3ad50b1..b94b8fc0e8d5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -134,7 +134,7 @@ "react-native-pdf-jsi": "5.0.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.36.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.37.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.16.0", "react-native-webview": "14.0.1", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 819f780ed7d3..2ab9a1a191b2 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -9,6 +9,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context"; import { createStaticNavigation, getStateFromPath } from "@react-navigation/native"; import { RegistryContext } from "@effect/atom-react"; +import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages"; import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet"; import * as Dialog from "./components/ui/Dialog"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; @@ -110,6 +111,7 @@ function AppContent() { + {/* Anchored-menu overlays render here — in-window, so the diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index d5a8a038fe43..8c7e4709d713 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -34,6 +34,7 @@ import { import { PullRequestFilesSheet } from "./features/pull-requests/PullRequestFiles"; import { PullRequestReviewSheet } from "./features/pull-requests/PullRequestReviewSheet"; import { PullRequestReviewersScreen } from "./features/pull-requests/PullRequestReviewersScreen"; +import { PullRequestFilterSearchScreen } from "./features/pull-requests/PullRequestFilterSearchScreen"; import { RenderErrorBoundary, RenderFailureView, @@ -147,6 +148,7 @@ const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Pl type AppScreenOptions = NativeStackNavigationOptions & { readonly unstable_navigationItemStyle?: "editor"; + readonly unstable_headerMinimizationBehavior?: "onScrollUp"; }; const GLASS_HEADER_OPTIONS: AppScreenOptions = { @@ -169,6 +171,13 @@ const DETAIL_HEADER_OPTIONS: AppScreenOptions = { headerShown: Platform.OS !== "android", }; +// Like Messages on the Duo: reading back through history (scrolling up) minimizes the +// header, and reversing toward the latest message restores it. Other devices ignore it. +const THREAD_HEADER_OPTIONS: AppScreenOptions = { + ...DETAIL_HEADER_OPTIONS, + unstable_headerMinimizationBehavior: "onScrollUp", +}; + const SOLID_HEADER_OPTIONS: AppScreenOptions = { headerBackButtonDisplayMode: "minimal", headerBackTitle: "", @@ -681,6 +690,21 @@ const RootStackConfig = createWorkspaceStackNavigator({ headerBackVisible: false, }, }), + PullRequestFilterSearch: createNativeStackScreen({ + screen: PullRequestFilterSearchScreen, + // Android pushes it like the branch picker; a formSheet there leaves the list's header + // peeking above it once the keyboard opens. + options: + Platform.OS === "android" + ? { presentation: "card", headerShown: false } + : { + ...DETAIL_HEADER_OPTIONS, + ...HORIZONTAL_FORM_SHEET_OPTIONS, + sheetAllowedDetents: formSheetDetents(0.75), + sheetGrabberVisible: true, + headerBackVisible: false, + }, + }), PullRequestDiscussion: createNativeStackScreen({ screen: PullRequestDiscussionScreen, options: DETAIL_HEADER_OPTIONS, @@ -707,7 +731,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ Thread: createNativeStackScreen({ screen: ThreadRouteScreen, linking: { path: "threads/:environmentId?/:threadId?", alias: ["new/draft"] }, - options: DETAIL_HEADER_OPTIONS, + options: THREAD_HEADER_OPTIONS, }), NewTaskProject: createNativeStackScreen({ screen: NewTaskRouteScreen, diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 625e3d3893e8..1dd939b36cb6 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -14,6 +14,7 @@ import { Atom } from "effect/reactivity"; import { projectFaviconUrlAtom } from "../state/assets"; import { countGlyphs, + projectAutoMonogram, projectIconColorClassNames, resolveProjectIconGlyph, type ProjectIconGlyph, @@ -167,6 +168,7 @@ function ProjectFaviconImage(props: { const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; const showImage = requestIsActive && status === "loaded"; + const fallbackGlyph = showImage ? null : projectAutoMonogram(props.projectTitle); return ( - {!showImage ? ( + {showImage ? null : fallbackGlyph ? ( + + ) : ( - ) : null} + )} {requestIsActive ? ( void; readonly destructive?: boolean; readonly disabled?: boolean; @@ -40,13 +42,15 @@ function MenuIcon(props: { name={props.name} size={iconSize} type="monochrome" - tintColorClassName={ - props.disabled - ? "accent-icon-subtle" - : props.destructive - ? "accent-danger-foreground" - : "accent-foreground" - } + {...(typeof props.color === "string" && !props.disabled + ? { tintColor: props.color } + : { + tintColorClassName: props.disabled + ? "accent-icon-subtle" + : props.destructive + ? "accent-danger-foreground" + : "accent-foreground", + })} /> @@ -96,6 +100,7 @@ export function MenuPopup(props: MenuPopupProps) { props.onPress(action)} destructive={action.attributes?.destructive} disabled={action.attributes?.disabled} diff --git a/apps/mobile/src/components/ui/SelectionRow.tsx b/apps/mobile/src/components/ui/SelectionRow.tsx new file mode 100644 index 000000000000..db47aaeca119 --- /dev/null +++ b/apps/mobile/src/components/ui/SelectionRow.tsx @@ -0,0 +1,104 @@ +import type { ReactNode } from "react"; +import { Platform, Pressable, View } from "react-native"; + +import { cn } from "../../lib/cn"; +import { SymbolView, type AppSymbolName } from "./AppSymbol"; +import { AppText as Text } from "./AppText"; +import { NavigationRow } from "./NavigationRow"; + +/** A picker row in a grouped list; the first and last rows round the group's corners. */ +export function SelectionRow(props: { + readonly icon?: AppSymbolName; + /** Drawn where the icon goes, for marks a symbol can't express (a label's color dot). */ + readonly leading?: ReactNode; + readonly onPress: () => void; + readonly disabled?: boolean; + readonly selected: boolean; + /** "checkbox" when several rows can be selected at once. */ + readonly role?: "radio" | "checkbox"; + readonly isFirst?: boolean; + readonly isLast?: boolean; + readonly subtitle?: string; + readonly title: string; +}) { + const role = props.role ?? "radio"; + const row = + Platform.OS === "android" ? ( + + ) : ( + props.leading + ) + } + trailing={ + props.selected ? ( + + ) : null + } + accessibilityRole={role} + accessibilityState={{ checked: props.selected }} + disabled={props.disabled} + onPress={props.onPress} + /> + ) : ( + + {props.icon ? ( + + ) : ( + (props.leading ?? null) + )} + + + {props.title} + + {props.subtitle ? ( + + {props.subtitle} + + ) : null} + + {props.selected ? ( + + ) : null} + + ); + if (!props.isFirst && !props.isLast) return row; + return ( + + {row} + + ); +} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 389054aa1035..2de56b7c391c 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -45,7 +45,6 @@ import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects"; -import { useRestingTopOffset } from "../../native/useRestingTopOffset"; import { useNativeColumnLayoutMetrics, useNativeLayoutMetrics, @@ -218,9 +217,7 @@ function HomeTopContentSpacer() { } function HomeScrollView(props: ComponentProps) { - const insets = useSafeAreaInsets(); const primaryColumn = use(NativePrimaryColumnContext); - const restingOffset = useRestingTopOffset(insets.top, props); if (Platform.OS !== "ios") return ; // v5 needs the actual content scroll view registered with its screen controller. @@ -235,14 +232,8 @@ function HomeScrollView(props: ComponentProps) { > ); @@ -885,8 +876,9 @@ export function HomeScreen(props: HomeScreenProps) { ListEmptyComponent={v2ListEmpty} style={{ flex: 1 }} automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} - contentInsetAdjustmentBehavior="never" - contentInset={Platform.OS === "ios" ? { top: insets.top } : undefined} + // UIKit owns the bar's inset, so a minimizing bar and a resized column move + // the list in the same frame as the bar. + contentInsetAdjustmentBehavior="automatic" contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} initialScrollOffset={ initialScrollOffset > 0 @@ -905,7 +897,7 @@ export function HomeScreen(props: HomeScreenProps) { (contentSideInsets?.right ?? 0) + (primaryColumn ? 8 : 0) + androidListGutter, paddingBottom: Platform.OS === "ios" - ? iosBottomClearance + ? iosBottomClearance - (columnMetrics?.safeArea.bottom ?? insets.bottom) : (workspace?.bottomClearance ?? Math.max(insets.bottom, 16) + (Platform.OS === "android" ? fabClearance : 88)), diff --git a/apps/mobile/src/features/home/ThreadUndoNotice.android.tsx b/apps/mobile/src/features/home/ThreadUndoNotice.android.tsx new file mode 100644 index 000000000000..d1caf4e664b7 --- /dev/null +++ b/apps/mobile/src/features/home/ThreadUndoNotice.android.tsx @@ -0,0 +1,128 @@ +import { useLayoutEffect, useState } from "react"; +import { StyleSheet, View } from "react-native"; +import { GestureDetector, usePanGesture } from "react-native-gesture-handler"; +import Animated, { + cancelAnimation, + Easing, + FadeOut, + ReduceMotion, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, + type EntryAnimationsValues, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Toast } from "../../components/ui/Toast"; +import { MOBILE_MOTION } from "../../lib/designTokens"; +import { + dismissThreadUndoNotice, + readThreadUndoNotice, + useThreadUndoNotice, +} from "./thread-undo-notice"; + +const SWIPE_TIMING = { duration: 160, reduceMotion: ReduceMotion.System }; +const ENTER_TIMING = { + duration: MOBILE_MOTION.enter, + easing: Easing.out(Easing.cubic), + reduceMotion: ReduceMotion.System, +}; +const NOTICE_EXITING = FadeOut.duration(MOBILE_MOTION.exit).reduceMotion(ReduceMotion.System); +const noticeEntering = (values: EntryAnimationsValues) => { + "worklet"; + return { + initialValues: { originY: values.targetOriginY + values.targetHeight + 12 }, + animations: { originY: withTiming(values.targetOriginY, ENTER_TIMING) }, + }; +}; + +export function ThreadUndoNotice(props: { readonly visible: boolean }) { + const notice = useThreadUndoNotice(); + // Replacing a notice must also cancel the native gesture tracking the old one. + const [instance, setInstance] = useState({ notice, key: 0 }); + if (instance.notice !== notice) setInstance({ notice, key: instance.key + 1 }); + if (!props.visible || notice === null) return null; + return ; +} + +function SwipeableUndoNotice(props: { + readonly notice: NonNullable>; +}) { + const { notice } = props; + const insets = useSafeAreaInsets(); + const translation = useSharedValue(0); + const width = useSharedValue(0); + + useLayoutEffect( + () => () => { + cancelAnimation(translation); + }, + [translation], + ); + + const dismiss = () => { + // A newer action can replace the notice while the swipe is animating out. + if (readThreadUndoNotice() === notice) dismissThreadUndoNotice(); + }; + const gesture = usePanGesture({ + activeOffsetX: [-10, 10], + failOffsetY: [-12, 12], + onUpdate: (event) => { + translation.set(event.translationX); + }, + onDeactivate: (event) => { + if ( + !event.canceled && + (Math.abs(event.translationX) > width.value * 0.25 || + (Math.abs(event.velocityX) > 800 && event.translationX * event.velocityX > 0)) + ) { + const direction = Math.sign(event.translationX || event.velocityX); + translation.set( + withTiming(direction * (width.value + 24), SWIPE_TIMING, (finished) => { + if (finished) runOnJS(dismiss)(); + }), + ); + } else { + translation.set(withTiming(0, SWIPE_TIMING)); + } + }, + }); + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translation.value }], + })); + + // Clip the entrance above navigation; the notice intentionally covers the anchored FAB. + return ( + { + width.set(event.nativeEvent.layout.width); + }} + > + + + {/* Keep the dismiss target separate so TalkBack can also focus Undo. */} + { + if (nativeEvent.actionName === "dismiss") dismiss(); + }} + pointerEvents="none" + style={StyleSheet.absoluteFill} + /> + void notice.undo() }} + /> + + + + ); +} diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index c70062ab04c7..99cbe597bcc1 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -18,6 +18,7 @@ import { import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { ProjectFavicon } from "../../components/ProjectFavicon"; import { GlassSurface } from "../../components/ui/GlassSurface"; import { RowPressable } from "../../components/ui/RowPressable"; import { AppText as Text } from "../../components/ui/AppText"; @@ -68,7 +69,6 @@ const ACTION_ICONS: Record = { }; function itemIcon(item: CommandPaletteItem): AppSymbolName { - if (item.kind === "project") return "folder"; if (item.kind === "thread") return "text.bubble"; return ACTION_ICONS[item.key] ?? "ellipsis"; } @@ -101,11 +101,24 @@ function PaletteRow(props: { style={{ height: ROW_HEIGHT }} > - + {props.item.project ? ( + + ) : ( + + )} @@ -310,6 +323,7 @@ export function CommandPalette(props: { const projectItems: CommandPaletteItem[] = projects.map((project) => ({ key: `project:${scopedProjectKey(project.environmentId, project.id)}`, kind: "project", + project, title: project.title, detail: `New thread · ${savedConnectionsById[project.environmentId]?.environmentLabel ?? project.environmentId}`, searchTerms: [project.workspaceRoot, "new thread", "project"], @@ -324,7 +338,10 @@ export function CommandPalette(props: { }), })); const threadItems: CommandPaletteItem[] = threads - .filter((thread) => thread.archivedAt === null) + .filter( + (thread) => + thread.archivedAt === null && thread.lineage.relationshipToParent !== "subagent", + ) .sort((left, right) => (right.latestUserMessageAt ?? right.updatedAt).localeCompare( left.latestUserMessageAt ?? left.updatedAt, diff --git a/apps/mobile/src/features/keyboard/commandPaletteItems.ts b/apps/mobile/src/features/keyboard/commandPaletteItems.ts index 2e7c2399ae49..035d23e89d7d 100644 --- a/apps/mobile/src/features/keyboard/commandPaletteItems.ts +++ b/apps/mobile/src/features/keyboard/commandPaletteItems.ts @@ -1,6 +1,9 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; + export interface CommandPaletteItem { readonly key: string; readonly kind: "action" | "project" | "thread"; + readonly project?: EnvironmentProject; readonly title: string; readonly detail?: string; readonly searchTerms: ReadonlyArray; diff --git a/apps/mobile/src/features/pull-requests/PullRequestFilterSearchScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestFilterSearchScreen.tsx new file mode 100644 index 000000000000..09bd4e2628b4 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestFilterSearchScreen.tsx @@ -0,0 +1,244 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { + EnvironmentId, + ProjectId, + type PullRequestFilterOptionKind, + type PullRequestFilterOptionsInput, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { useRef, useState } from "react"; +import { FlatList, Platform, View, type TextInputInstance } from "react-native"; + +import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; +import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; +import { EmptyState } from "../../components/ui/EmptyState"; +import { SearchField } from "../../components/ui/SearchField"; +import { SelectionRow } from "../../components/ui/SelectionRow"; +import { SheetHeader } from "../../components/ui/SheetHeader"; +import { Toolbar } from "../../components/ui/Toolbar"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; +import { composerPullRequests } from "../../state/pull-requests"; +import { useDebouncedValue } from "../../state/queries"; +import { + createNativeMailSearchToolbarItem, + NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, +} from "../layout/native-mail-search-toolbar"; +import { pullRequestFilterChoices, pullRequestLabelColors } from "./pullRequestList"; +import { usePullRequestListControls } from "./usePullRequestListControls"; + +export type PullRequestFilterSearchParams = { + readonly kind: PullRequestFilterOptionKind; + /** The list controls' own key: the environment the list is narrowed to, or every one. */ + readonly environmentId: string | null; + /** The environments the list reads, which the host search covers as well. */ + readonly environmentIds: readonly string[]; + /** What the loaded rows already carry, offered before the host has answered. */ + readonly suggestions: readonly string[]; + /** Dot colors by lowercased label name, from the loaded rows. */ + readonly labelColors?: Readonly>; +}; + +const optionsFamily = Atom.family((key: string) => { + const targets: readonly { + readonly environmentId: EnvironmentId; + readonly input: PullRequestFilterOptionsInput; + }[] = JSON.parse(key); + return Atom.make((get) => { + const results = targets.map((target) => get(composerPullRequests.filterOptions(target))); + return { + values: results.flatMap((result) => Option.toArray(AsyncResult.value(result))), + waiting: results.some((result) => result.waiting), + failed: results.some(AsyncResult.isFailure), + }; + }); +}); + +export function PullRequestFilterSearchScreen({ + route, +}: StaticScreenProps) { + const navigation = useNavigation(); + const { kind, environmentId, environmentIds, suggestions, labelColors } = route.params; + const listControls = usePullRequestListControls(); + const controls = listControls.controlsFor(environmentId); + const integratedSearch = useNativeMailSearchToolbar(); + const searchRef = useRef(null); + const [query, setQuery] = useState(""); + const typed = query.trim().slice(0, 100); + const sent = useDebouncedValue(typed, 250); + // Until the search catches up with the typing, the host's answers are for older text. + const settled = sent === typed; + const key = JSON.stringify( + environmentIds.map((id) => ({ + environmentId: EnvironmentId.make(id), + input: { + kind, + ...(controls.projectId ? { projectId: ProjectId.make(controls.projectId) } : {}), + ...(sent ? { query: sent } : {}), + }, + })), + ); + const hosted = useAtomValue(optionsFamily(key)); + const selectedLabels = controls.filters.labels?.flat() ?? []; + const author = controls.filters.author; + const selected = kind === "labels" ? selectedLabels : author ? [author] : []; + const rows = pullRequestFilterChoices({ + selected, + suggestions, + hosted: (settled ? hosted.values : []).flatMap((options) => + kind === "labels" + ? options.labels.map((label) => label.name) + : options.authors.map((candidate) => candidate.login), + ), + query, + }); + const colors = { + ...pullRequestLabelColors(hosted.values.flatMap((options) => options.labels)), + ...labelColors, + }; + const isSelected = (value: string) => + selected.some((held) => held.toLowerCase() === value.toLowerCase()); + const choose = (value: string) => { + if (kind === "authors") { + listControls.setControls(environmentId, { + ...controls, + filters: { ...controls.filters, author: isSelected(value) ? undefined : value }, + }); + navigation.goBack(); + return; + } + const labels = isSelected(value) + ? selectedLabels.filter((label) => label.toLowerCase() !== value.toLowerCase()) + : [...selectedLabels, value].slice(0, 10); + listControls.setControls(environmentId, { + ...controls, + filters: { + ...controls.filters, + labels: labels.length ? labels.map((label) => [label]) : undefined, + }, + }); + }; + const placeholder = kind === "labels" ? "Search labels" : "Search authors"; + const list = ( + value.toLowerCase()} + className="flex-1" + contentInsetAdjustmentBehavior="automatic" + automaticallyAdjustKeyboardInsets={Platform.OS === "ios"} + keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"} + keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: integratedSearch ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + 16 : 16, + }} + renderItem={({ item, index }) => ( + + ) : undefined + } + onPress={() => choose(item)} + /> + )} + ListEmptyComponent={ + hosted.waiting || !settled ? ( + + ) : ( + + ) + } + /> + ); + const title = kind === "labels" ? "Filter by label" : "Filter by author"; + if (Platform.OS === "android") { + return ( + + navigation.goBack()} /> + + + + {list} + + ); + } + return ( + + navigation.goBack()} + accessibilityLabel={kind === "labels" ? "Close label filter" : "Close author filter"} + /> + [ + createNativeMailSearchToolbarItem({ + placeholder, + onSearchTextChange: setQuery, + showsSearchDismissButton: true, + searchTextChangeId: "pr-filter-option-search", + }), + ] + : undefined, + headerSearchBarOptions: integratedSearch + ? undefined + : { + placeholder, + allowToolbarIntegration: true, + autoCapitalize: "none", + hideNavigationBar: false, + obscureBackground: false, + onChangeText: (event) => setQuery(event.nativeEvent.text), + onCancelButtonPress: () => setQuery(""), + }, + }} + /> + {integratedSearch ? null : ( + + + + )} + {list} + + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx index 2a9e71bc2f24..518d509332ce 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx @@ -73,7 +73,11 @@ export function PullRequestListFilters(props: { readonly onChange: (value: PullRequestListControls) => void; readonly authors: readonly string[]; readonly labels: readonly string[]; + /** Dot colors by lowercased label name, for the labels that have one. */ + readonly labelColors: Readonly>; readonly projects: readonly { id: string; title: string; environmentId: string }[]; + /** Opens a picker that searches the host, for a label or author no loaded row carries. */ + readonly onSearch: (kind: "labels" | "authors") => void; }) { const { value } = props; const projects = useProjectsInSidebarOrder(props.projects); @@ -145,6 +149,9 @@ export function PullRequestListFilters(props: { case "author": props.onChange({ ...value, filters: { ...value.filters, author: next || undefined } }); break; + case "search": + if (next === "labels" || next === "authors") props.onSearch(next); + break; case "draft": props.onChange({ ...value, @@ -248,22 +255,30 @@ export function PullRequestListFilters(props: { const stateActions = choices("state", STATES, value.state); const involvementActions = choices("involvement", INVOLVEMENT, value.involvement); const labelActions: MenuProps["actions"] = [ + { id: id("search", "labels"), title: "Search labels…", image: "magnifyingglass" }, { id: id("labels", ""), title: "All labels", state: selectedLabels.length ? "off" : "on" }, - ...labels.map((label) => ({ - id: id("label", label), - title: label, - state: selectedLabelSet.has(label) ? ("on" as const) : ("off" as const), - })), + ...labels.map((label) => { + const color = props.labelColors[label.toLowerCase()]; + return { + id: id("label", label), + title: label, + state: selectedLabelSet.has(label) ? ("on" as const) : ("off" as const), + ...(color ? { image: "circle.fill", imageColor: color } : {}), + }; + }), + ]; + const authorActions: MenuProps["actions"] = [ + { id: id("search", "authors"), title: "Search authors…", image: "magnifyingglass" }, + ...choices( + "author", + Object.fromEntries([ + ["", "All authors"], + ["me", "Me"], + ...authors.map((login) => [login, login]), + ]), + author ?? "", + ), ]; - const authorActions = choices( - "author", - Object.fromEntries([ - ["", "All authors"], - ["me", "Me"], - ...authors.map((login) => [login, login]), - ]), - author ?? "", - ); const customized = count > 0 || value.sort !== "updated" || props.environmentId !== null; // The Android header's filter button opens every filter. const headerMenu = diff --git a/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx index af471abf2c86..ab609d42456b 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx @@ -20,7 +20,6 @@ import { ScrollViewMarker } from "react-native-screens"; import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText"; import { EmptyState } from "../../components/ui/EmptyState"; import { Button } from "../../components/ui/Button"; -import { useRestingTopOffset } from "../../native/useRestingTopOffset"; import { SymbolView } from "../../components/ui/AppSymbol"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; @@ -35,17 +34,9 @@ import { PullRequestAvatar } from "./PullRequestPresentation"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; function ReviewerScrollView(props: ScrollViewProps) { - const insets = useSafeAreaInsets(); - const restingOffset = useRestingTopOffset(insets.top, props); return ( - + ); } diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index a513fd424ced..84f9ea16f4bb 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -56,7 +56,6 @@ import { View, } from "react-native"; import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker"; -import { useRestingTopOffset } from "../../native/useRestingTopOffset"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; @@ -117,16 +116,9 @@ import { usePullRequestEnvironmentFallback } from "./usePullRequestEnvironmentFa const EMPTY_IDS: readonly string[] = []; function PullRequestTabScrollView(props: ScrollViewProps) { - const insets = useSafeAreaInsets(); - const restingOffset = useRestingTopOffset(insets.top, props); return ( - + ); } @@ -572,10 +564,9 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps - + ); } @@ -200,8 +192,14 @@ export function PullRequestsScreen( ...facets.labels, ]), ].sort(), + labelColors: { + ...facets.labelColors, + ...(previous.environmentId === environmentId ? previous.labelColors : {}), + }, })); } + const labelColors = + knownFacets.environmentId === environmentId ? knownFacets.labelColors : facets.labelColors; const projects = allProjects.filter( (project) => !project.isScratch && (environmentId === null || project.environmentId === environmentId), @@ -260,8 +258,9 @@ export function PullRequestsScreen( showsVerticalScrollIndicator={Platform.OS !== "android"} recycleItems renderScrollComponent={Platform.OS === "ios" ? PullRequestScrollView : undefined} - contentInsetAdjustmentBehavior="never" - contentInset={Platform.OS === "ios" ? { top: insets.top } : undefined} + // UIKit owns the bar's inset, so a minimizing bar and a resized column move + // the list in the same frame as the bar. + contentInsetAdjustmentBehavior="automatic" // A plain offset: LegendList adds the list header to an index-based restore, which // landed every return one header lower. initialScrollOffset={ @@ -269,7 +268,9 @@ export function PullRequestsScreen( } contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} contentContainerStyle={{ - paddingBottom: Math.max(insets.bottom, workspace.bottomClearance), + paddingBottom: + Math.max(insets.bottom, workspace.bottomClearance) - + (Platform.OS === "ios" ? insets.bottom : 0), }} onScroll={(event) => { workspace.recordScrollOffset( @@ -318,7 +319,18 @@ export function PullRequestsScreen( labels={ knownFacets.environmentId === environmentId ? knownFacets.labels : facets.labels } + labelColors={labelColors} projects={projects} + onSearch={(kind) => + navigation.navigate("PullRequestFilterSearch", { + kind, + environmentId, + environmentIds: targets.map((target) => String(target.environmentId)), + suggestions: + knownFacets.environmentId === environmentId ? knownFacets[kind] : facets[kind], + labelColors, + }) + } /> {result.data?.errors.length ? ( void result.loadMore(true)} /> + ) : result.hasMore ? ( + // A filtered page can be too short to scroll, which leaves nothing to reach the end of. + + -
+