diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 611d4cf44f75..f65c15696d2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,9 @@ jobs: - name: Check run: vp check + - name: Check className overrides on components/ui do not grow + run: vp run lint:restyle-ceiling + - name: Typecheck run: vpr typecheck diff --git a/AGENTS.md b/AGENTS.md index 38df1e94fa8c..2f3265d7cafd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,6 +158,7 @@ Full glossary with file links: `docs/internals/glossary.md` ## Taste - Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb. +- `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits, add a variant to the component, not classes at the call site. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` reports violations and CI caps their count. - Inferred types over annotations. `any` is the enemy. - Comments describe how a thing is used, and move when the code moves. To be used mostly to describe functions, not to annotate every line of behavior. - Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 226793657848..f49b6dbd968e 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -113,3 +113,5 @@ export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper"; export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission"; + +export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 5fb7eff99fc6..36cf7c9abe15 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -30,11 +30,13 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; import * as NodeURL from "node:url"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts"; import * as PreviewManager from "../../preview/Manager.ts"; +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; @@ -50,6 +52,9 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribeRecordingFrames((frame) => electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame), ); + yield* manager.subscribeRecordingInputs((event) => + electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, event), + ); yield* manager.subscribePointerEvents((event) => electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event), ); @@ -180,11 +185,21 @@ export const cancelPickElement = tabMethod( "desktop.ipc.preview.cancelPickElement", (manager, tabId) => manager.cancelPickElement(tabId), ); -export const startRecording = tabMethod( - IpcChannels.PREVIEW_RECORDING_START_CHANNEL, - "desktop.ipc.preview.startRecording", - (manager, tabId) => manager.startRecording(tabId), -); +export const startRecording = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + const store = yield* DesktopClientSettings.DesktopClientSettings; + const settings = yield* store.get; + const options = Option.map(settings, (value) => ({ + showKeyPresses: value.browserRecordingShowKeyPresses, + showMousePresses: value.browserRecordingShowMousePresses, + })); + yield* manager.startRecording(tabId, Option.getOrUndefined(options)); + }), +}); export const stopRecording = tabMethod( IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, "desktop.ipc.preview.stopRecording", diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 885b31a64d6b..7256f610fbeb 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,6 +1,7 @@ import type { DesktopBridge, DesktopPreviewPointerEvent, + DesktopPreviewRecordingInputEvent, DesktopPreviewRecordingFrame, DesktopPreviewTabState, DesktopSnapShotEvent, @@ -327,6 +328,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, { tabId }), }, recording: { + onInput: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, event: unknown) => { + if (typeof event !== "object" || event === null) return; + listener(event as DesktopPreviewRecordingInputEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, wrappedListener); + }, startScreencast: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), stopScreencast: (tabId) => diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index e63597b71efc..1a73bb30f29e 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -5,3 +5,8 @@ export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; +export const RECORDING_CURSOR_CHANNEL = "preview:recording-cursor"; +export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; +export const RECORDING_KEY_CHANNEL = "preview:recording-key"; +export const RECORDING_INPUT_CHANNEL = "preview:recording-input"; +export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 7b76a1b8003a..304efeafefe9 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,7 +1,10 @@ import * as NodeVM from "node:vm"; import { it as effectIt } from "@effect/vitest"; import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; -import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; +import type { + DesktopPreviewRecordingFrame, + DesktopPreviewRecordingInputEvent, +} from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; @@ -2885,6 +2888,165 @@ describe("PreviewManager", () => { ), ); + effectIt.effect( + "restores the native cursor when recording startup fails, then allows a retry", + () => + withManager((manager) => + Effect.gen(function* () { + const host = makeTestHostWebContents(); + host.executeJavaScript.mockResolvedValueOnce(false); + let cursorActive = false; + const cursorAtCapture: boolean[] = []; + const contents = Object.assign( + makeTestPreviewWebContents( + async () => { + cursorAtCapture.push(cursorActive); + return { + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + }; + }, + 42, + host, + ), + { + send: (channel: string, active: unknown) => { + if (channel === "preview:recording-cursor") cursorActive = active === true; + }, + }, + ); + fromId.mockReturnValue(contents as never); + yield* manager.createTab("tab_cursor"); + yield* manager.registerWebview("tab_cursor", 42); + const failed = yield* Effect.exit(manager.startRecording("tab_cursor")); + expect(Exit.isFailure(failed)).toBe(true); + expect(cursorAtCapture).toEqual([true]); + expect(cursorActive).toBe(false); + + yield* manager.startRecording("tab_cursor"); + expect(cursorAtCapture).toEqual([true, true]); + expect(cursorActive).toBe(true); + yield* manager.stopRecording("tab_cursor"); + expect(cursorActive).toBe(false); + }), + ), + ); + + effectIt.effect("restores the recording cursor after navigation only while recording", () => + withManager((manager) => + Effect.gen(function* () { + const listeners = new Map void>(); + let cursorActive = false; + let cursorUpdated: (() => void) | undefined; + let inputOptions: unknown; + const options = { showKeyPresses: true, showMousePresses: false }; + const contents = Object.assign( + makeTestPreviewWebContents(async () => ({ + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + })), + { + on: (event: string, listener: () => void) => listeners.set(event, listener), + send: (channel: string, active: unknown, recordingOptions: unknown) => { + if (channel !== "preview:recording-cursor") return; + cursorActive = active === true; + inputOptions = recordingOptions; + cursorUpdated?.(); + }, + }, + ); + fromId.mockReturnValue(contents as never); + yield* manager.createTab("tab_cursor_reload"); + yield* manager.registerWebview("tab_cursor_reload", 42); + yield* manager.startRecording("tab_cursor_reload", options); + for (const recording of [true, false]) { + if (!recording) yield* manager.stopRecording("tab_cursor_reload"); + // A new document has lost the previous preload's cursor overlay. + cursorActive = false; + const restored = new Promise((resolve) => { + cursorUpdated = resolve; + }); + listeners.get("dom-ready")?.(); + yield* Effect.promise(() => restored); + cursorUpdated = undefined; + expect(cursorActive).toBe(recording); + expect(inputOptions).toEqual(recording ? options : undefined); + } + }), + ), + ); + + effectIt.effect("gates recording decorations and isolates failed subscribers", () => + withManager((manager) => + Effect.gen(function* () { + const callbacks = new Map< + string, + (event: unknown, input: unknown) => Fiber.Fiber | undefined + >(); + const contents = Object.assign( + makeTestPreviewWebContents(async () => ({ + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + })), + { + ipc: { + on: ( + channel: string, + callback: (event: unknown, input: unknown) => Fiber.Fiber | undefined, + ) => callbacks.set(channel, callback), + off: vi.fn(), + }, + }, + ); + fromId.mockReturnValue(contents as never); + yield* manager.createTab("tab_recording_input"); + yield* manager.registerWebview("tab_recording_input", 42); + const received: DesktopPreviewRecordingInputEvent[] = []; + yield* manager.subscribeRecordingInputs(() => Effect.die("renderer unavailable")); + yield* manager.subscribeRecordingInputs((event) => + Effect.sync(() => { + received.push(event); + }), + ); + const send = (input: unknown) => + Effect.gen(function* () { + const fiber = callbacks.get("preview:recording-input")?.(null, input); + if (fiber) yield* Fiber.join(fiber); + }); + const key = { type: "key", label: "⌘C", held: true, width: 800 }; + const pointer = { + type: "pointer", + phase: "down", + x: 120, + y: 80, + width: 800, + height: 600, + }; + yield* send(key); + expect(received).toEqual([]); + yield* manager.startRecording("tab_recording_input", { + showKeyPresses: true, + showMousePresses: false, + }); + yield* send(key); + yield* send(pointer); + yield* send({ ...key, width: 0 }); + expect(received).toEqual([{ tabId: "tab_recording_input", input: key }]); + yield* manager.stopRecording("tab_recording_input"); + yield* send(key); + expect(received).toHaveLength(1); + yield* manager.startRecording("tab_recording_input", { + showKeyPresses: false, + showMousePresses: true, + }); + yield* send(key); + yield* send(pointer); + expect(received.at(-1)).toEqual({ tabId: "tab_recording_input", input: pointer }); + expect(received).toHaveLength(2); + }), + ), + ); + effectIt.effect("continues native recording when the source warmup fails", () => withManager((manager) => Effect.gen(function* () { @@ -3853,88 +4015,108 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("emits the resolved pointer target before dispatching an automation click", () => - withManager((manager) => - Effect.gen(function* () { - let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; - const activity: string[] = []; - const sendCommand = vi.fn(async (method: string, params?: Record) => { - if (method === "Runtime.evaluate") { - return { - result: { - value: { width: 800, height: 600 }, - }, - }; - } - if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { - activity.push("mousePressed"); - humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); - } - return undefined; - }); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoading: () => false, - isDevToolsOpened: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { - on: vi.fn((channel: string, listener: typeof humanInput) => { - if (channel === "preview:human-input") humanInput = listener; - }), - off: vi.fn(), - }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand, + effectIt.effect( + "records the resolved pointer target before dispatching an automation click", + () => + withManager((manager) => + Effect.gen(function* () { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const activity: string[] = []; + const sendCommand = vi.fn(async (method: string, params?: Record) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: { width: 800, height: 600 }, + }, + }; + } + if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { + activity.push("mousePressed"); + humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); + } + return undefined; + }); + fromId.mockReturnValue({ + id: 42, + hostWebContents: makeTestHostWebContents(), + capturePage: vi.fn(async () => ({ toPNG: () => Buffer.from("frame") })), + setBackgroundThrottling: vi.fn(), + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), - }, - } as never); - - yield* manager.subscribePointerEvents((event) => - Effect.sync(() => { - activity.push(event.phase); - }), - ); - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - const click = yield* manager - .automationClick("tab_1", { x: 120, y: 80 }) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(200); - yield* Fiber.join(click); + ipc: { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); - expect(activity).toEqual(["move", "click", "mousePressed"]); - expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { - type: "mousePressed", - x: 120, - y: 80, - button: "left", - clickCount: 1, - }); - expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { - type: "mouseReleased", - x: 120, - y: 80, - button: "left", - clickCount: 1, - }); - }), - ), + yield* manager.subscribePointerEvents((event) => + Effect.sync(() => { + activity.push(event.phase); + }), + ); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + yield* manager.startRecording("tab_1"); + const click = yield* manager + .automationClick("tab_1", { x: 120, y: 80 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(200); + yield* Fiber.join(click); + + expect(activity).toEqual(["move", "click", "mousePressed"]); + expect( + webviewSend.mock.calls + .filter(([channel]) => channel === "preview:recording-controller") + .map(([, controller]) => controller), + ).toEqual(["agent", "none"]); + + const recordedPointer = webviewSend.mock.calls + .filter(([channel]) => channel === "preview:recording-pointer") + .map(([, event]) => event); + expect(recordedPointer).toEqual([ + expect.objectContaining({ phase: "move", x: 120, y: 80 }), + expect.objectContaining({ phase: "click", x: 120, y: 80 }), + ]); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mousePressed", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + yield* manager.stopRecording("tab_1"); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + }), + ), ); effectIt.effect("types in background webviews and enables native key input", () => @@ -4230,6 +4412,9 @@ describe("PreviewManager", () => { }); fromId.mockReturnValue({ id: 42, + hostWebContents: makeTestHostWebContents(), + capturePage: vi.fn(async () => ({ toPNG: () => Buffer.from("frame") })), + setBackgroundThrottling: vi.fn(), isDestroyed: () => false, getType: () => "webview", getURL: () => "https://example.com", @@ -4263,6 +4448,7 @@ describe("PreviewManager", () => { yield* manager.createTab("tab_1"); yield* manager.registerWebview("tab_1", 42); + yield* manager.startRecording("tab_1"); const click = yield* manager .automationClick("tab_1", { x: 120, y: 80 }) @@ -4270,6 +4456,12 @@ describe("PreviewManager", () => { yield* TestClock.adjust(200); const exit = yield* Fiber.await(click); expect(Exit.isFailure(exit)).toBe(true); + expect( + webviewSend.mock.calls + .filter(([channel]) => channel === "preview:recording-controller") + .map(([, controller]) => controller), + ).toEqual(["agent", "human", "none"]); + yield* manager.stopRecording("tab_1"); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); expect(error).toMatchObject({ diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index a2e34fe54736..468c47065315 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -6,7 +6,10 @@ * here). Single layer-scoped browser session partition. */ import * as NodeCrypto from "node:crypto"; -import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; +import { + DesktopPreviewRecordingInputSchema, + DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, +} from "@t3tools/contracts"; import type { DesktopPreviewAnnotationTheme, DesktopPreviewAutomationStatus, @@ -18,6 +21,7 @@ import type { PreviewAnnotationSubmissionResult, DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, + DesktopPreviewRecordingInputEvent, DesktopPreviewScreenshotArtifact, DesktopPreviewTabDefaults, PreviewAutomationClickInput, @@ -71,6 +75,11 @@ import { ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + RECORDING_CURSOR_CHANNEL, + RECORDING_POINTER_CHANNEL, + RECORDING_KEY_CHANNEL, + RECORDING_INPUT_CHANNEL, + RECORDING_CONTROLLER_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; @@ -81,6 +90,7 @@ import { previewAutomationEditingCommandExpression, } from "./PreviewKeyboard.ts"; import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; +import { DEFAULT_RECORDING_INPUT_OPTIONS, type RecordingInputOptions } from "./RecordingInput.ts"; export type PreviewNavStatus = | { kind: "Idle" } @@ -437,6 +447,7 @@ interface ManagedListeners { type FrameCaptureConsumer = "picture-in-picture" | "recording"; interface FrameCaptureSession { + readonly recordingInputOptions?: RecordingInputOptions; readonly scope: Scope.Closeable | null; readonly consumers: ReadonlySet; readonly unthrottledWebContentsIds: ReadonlySet; @@ -485,6 +496,10 @@ interface BrowserDiagnostics { readonly requests: ReadonlyMap; } +const isRecordingInput = Schema.is(DesktopPreviewRecordingInputSchema); + +type RecordingInputListener = (event: DesktopPreviewRecordingInputEvent) => Effect.Effect; + type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect; interface ExpectedAgentInput { @@ -636,6 +651,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const attachedRef = yield* Ref.make>(new Map()); const listenersRef = yield* Ref.make>(new Set()); const pointerEventListenersRef = yield* Ref.make>(new Set()); + const recordingInputListenersRef = yield* Ref.make>( + new Set(), + ); const recordingFrameListenersRef = yield* Ref.make>( new Set(), ); @@ -821,6 +839,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return Effect.succeed([undefined, sessions] as const); } return setFrameCaptureWebContentsBackgroundThrottling(wc, false).pipe( + Effect.tap(() => + Effect.gen(function* () { + if (!current.consumers.has("recording")) return; + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + yield* attempt({ operation: "recording.cursor", tabId, webContentsId: wc.id }, () => + wc.send( + RECORDING_CURSOR_CHANNEL, + true, + current.recordingInputOptions, + tab?.controller, + ), + ); + }), + ), Effect.map( () => [ @@ -846,6 +878,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!current || !current.consumers.has(consumer)) { return [undefined, sessions] as const; } + if (consumer === "recording") { + yield* Effect.forEach(current.unthrottledWebContentsIds, (id) => + attempt({ operation: "recording.cursor", tabId, webContentsId: id }, () => { + const contents = webContents.fromId(id); + if (contents && !contents.isDestroyed()) + contents.send(RECORDING_CURSOR_CHANNEL, false); + }).pipe(Effect.ignore), + ); + } const consumers = new Set(current.consumers); consumers.delete(consumer); if (consumers.size > 0) { @@ -896,7 +937,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const deliverEvent = ( - eventKind: "state-change" | "recording-frame" | "pointer-event", + eventKind: "state-change" | "recording-frame" | "recording-input" | "pointer-event", tabId: string, delivery: () => Effect.Effect, ) => @@ -933,6 +974,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const update = Effect.fn("PreviewManager.update")(function* ( tabId: string, patch: Partial, + humanPoint?: { readonly x: number; readonly y: number }, ) { const updatedAt = yield* currentIso; const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { @@ -950,7 +992,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function // can commit between the modify above and here, and republishing this // snapshot would roll the UI back to a value that writer will not send // again because it suppresses unchanged audibility. - if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); + if (Option.isSome(next)) { + if (patch.controller !== undefined && next.value.webContentsId != null) { + const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + const webContentsId = next.value.webContentsId; + if (capture?.consumers.has("recording")) { + yield* attempt({ operation: "recording.controller", tabId }, () => { + const contents = webContents.fromId(webContentsId); + if (contents && !contents.isDestroyed()) + contents.send(RECORDING_CONTROLLER_CHANNEL, patch.controller, humanPoint); + }).pipe(Effect.ignore); + } + } + yield* emitIfCurrent(tabId, next.value); + } }); /** @@ -1728,6 +1783,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const sync = () => runFork(syncState(true)); const syncNavigation = () => runFork(syncState(false, true)); const syncInPageNavigation = () => runFork(syncState(false)); + const restoreRecordingCursor = () => + runFork( + Effect.gen(function* () { + const session = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + if (!wc.isDestroyed()) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + wc.send( + RECORDING_CURSOR_CHANNEL, + session?.consumers.has("recording") ?? false, + session?.recordingInputOptions, + tab?.controller, + ); + } + }), + ); const navigationStarted = ( event: Electron.Event, ) => { @@ -1860,13 +1930,37 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function copy.set(tabId, (epochs.get(tabId) ?? 0) + 1); }), ); - yield* update(tabId, { controller: "human" }); + yield* update( + tabId, + { controller: "human" }, + isPreviewInputSignal(rawSignal) && rawSignal.kind === "pointer" + ? { x: rawSignal.x, y: rawSignal.y } + : undefined, + ); yield* Effect.sleep(750); const tabs = yield* SynchronizedRef.get(tabsRef); if (tabs.get(tabId)?.controller === "human") { yield* update(tabId, { controller: "none" }); } }); + const recordingInput = (_event: unknown, input: unknown) => { + if (!isRecordingInput(input)) return; + return runFork( + Effect.gen(function* () { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + if (tab?.webContentsId !== wc.id || !capture?.consumers.has("recording")) return; + if (input.type === "key" && !capture.recordingInputOptions?.showKeyPresses) return; + if (input.type === "pointer" && !capture.recordingInputOptions?.showMousePresses) return; + const listeners = yield* Ref.get(recordingInputListenersRef); + yield* Effect.forEach( + listeners, + (listener) => deliverEvent("recording-input", tabId, () => listener({ tabId, input })), + { discard: true }, + ); + }), + ); + }; const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; @@ -1928,11 +2022,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("page-favicon-updated", faviconUpdated as never); wc.off("did-start-loading", sync); wc.off("did-stop-loading", sync); + wc.off("dom-ready", restoreRecordingCursor); wc.off("did-fail-load", failed as never); wc.off("audio-state-changed", audioStateChanged); wc.off("did-create-window", windowCreated); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); }).pipe(Effect.ignore), ); @@ -1948,9 +2044,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("page-favicon-updated", faviconUpdated as never); wc.on("did-start-loading", sync); wc.on("did-stop-loading", sync); + wc.on("dom-ready", restoreRecordingCursor); wc.on("did-fail-load", failed as never); wc.on("audio-state-changed", audioStateChanged); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.setWindowOpenHandler((details) => { if (previewWindowOpenAction(details) === "popup") { @@ -3405,7 +3503,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); }; - const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { + const startRecording = Effect.fn("PreviewManager.startRecording")(function* ( + tabId: string, + options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS, + ) { if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) { return yield* new PreviewTabNotFoundError({ tabId }); } @@ -3413,11 +3514,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId, Effect.gen(function* () { yield* startFrameCapture(tabId, "recording"); + yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => + replaceMap(sessions, (copy) => { + const current = copy.get(tabId); + if (current) copy.set(tabId, { ...current, recordingInputOptions: options }); + }), + ); const wc = yield* requireWebContents(tabId); const requestWebContents = wc.hostWebContents; if (requestWebContents === null) { return yield* new PreviewMainWindowClosedError({ tabId }); } + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + yield* attempt({ operation: "recording.cursor", tabId, webContentsId: wc.id }, () => + wc.send(RECORDING_CURSOR_CHANNEL, true, options, tab?.controller), + ); yield* attemptPromise( { operation: "recording.warmSource", @@ -3720,6 +3831,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const emitPointerEvent = Effect.fn("PreviewManager.emitPointerEvent")(function* ( event: DesktopPreviewPointerEvent, ) { + const recording = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(event.tabId); + const tab = (yield* SynchronizedRef.get(tabsRef)).get(event.tabId); + const webContentsId = tab?.webContentsId; + if (recording?.consumers.has("recording") && webContentsId != null) { + yield* attempt({ operation: "recording.pointer", tabId: event.tabId }, () => { + const contents = webContents.fromId(webContentsId); + if (contents && !contents.isDestroyed()) contents.send(RECORDING_POINTER_CHANNEL, event); + }); + } const listeners = yield* Ref.get(pointerEventListenersRef); yield* Effect.forEach( listeners, @@ -4110,6 +4230,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const keySequence = makePreviewAutomationNativeKeySequence(input, { isMac: hostPlatform === "darwin", }); + const recording = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + if (recording?.consumers.has("recording") && recording.recordingInputOptions?.showKeyPresses) { + yield* attempt({ operation: "recording.key", tabId, webContentsId: wc.id }, () => + wc.send(RECORDING_KEY_CHANNEL, { + key: keySequence.signal.key || input.key, + metaKey: input.modifiers?.includes("Meta") ?? false, + ctrlKey: input.modifiers?.includes("Control") ?? false, + altKey: input.modifiers?.includes("Alt") ?? false, + shiftKey: input.modifiers?.includes("Shift") ?? false, + }), + ); + } // CDP keyboard dispatch follows the embedder's focused renderer, and // WebContents.focus() is a no-op for webview guests. Native input targets // this guest's widget directly, so Enter cannot submit the host composer. @@ -4470,6 +4602,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Ref.set(expectedAgentInputsRef, new Map()), Ref.set(pointerEventListenersRef, new Set()), Ref.set(recordingFrameListenersRef, new Set()), + Ref.set(recordingInputListenersRef, new Set()), ], { discard: true }, ); @@ -4513,6 +4646,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function stopRecording, subscribePointerEvents: (listener: PointerEventListener) => subscribe(pointerEventListenersRef, listener), + subscribeRecordingInputs: (listener: RecordingInputListener) => + subscribe(recordingInputListenersRef, listener), subscribeRecordingFrames: (listener: RecordingFrameListener) => subscribe(recordingFrameListenersRef, listener), subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), @@ -4877,7 +5012,10 @@ export class PreviewManager extends Context.Service< readonly copyArtifactToClipboard: (path: string) => Effect.Effect; readonly openPictureInPicture: (tabId: string) => Effect.Effect; readonly closePictureInPicture: (tabId: string) => Effect.Effect; - readonly startRecording: (tabId: string) => Effect.Effect; + readonly startRecording: ( + tabId: string, + options?: RecordingInputOptions, + ) => Effect.Effect; readonly stopRecording: (tabId: string) => Effect.Effect; readonly saveRecording: ( tabId: string, @@ -4918,6 +5056,9 @@ export class PreviewManager extends Context.Service< readonly subscribePointerEvents: ( listener: PointerEventListener, ) => Effect.Effect; + readonly subscribeRecordingInputs: ( + listener: RecordingInputListener, + ) => Effect.Effect; readonly subscribeRecordingFrames: ( listener: RecordingFrameListener, ) => Effect.Effect; @@ -5011,6 +5152,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, subscribeRecordingFrames: operations.subscribeRecordingFrames, + subscribeRecordingInputs: operations.subscribeRecordingInputs, }); }).pipe(Effect.withSpan("PreviewManager.make")); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 6155c4119ec8..78351e9d79c3 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -16,6 +16,8 @@ import type { import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; +import { installRecordingCursor } from "./RecordingCursor.ts"; +import { DEFAULT_RECORDING_INPUT_OPTIONS } from "./RecordingInput.ts"; import { ANNOTATION_CAPTURED_CHANNEL, ANNOTATION_THEME_CHANNEL, @@ -23,6 +25,11 @@ import { ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + RECORDING_CURSOR_CHANNEL, + RECORDING_POINTER_CHANNEL, + RECORDING_KEY_CHANNEL, + RECORDING_INPUT_CHANNEL, + RECORDING_CONTROLLER_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; @@ -35,6 +42,80 @@ const ELEMENT_CONTEXT_TIMEOUT_MS = 5_000; const CONTENT_LAYER_Z_INDEX = 1; const CHROME_LAYER_Z_INDEX = 10; +let recordingCursor: ReturnType | null = null; +ipcRenderer.on( + RECORDING_CURSOR_CHANNEL, + (_event, active: unknown, inputOptions: unknown, controller: unknown) => { + if (active === true) { + const options = + typeof inputOptions === "object" && inputOptions !== null + ? { + showKeyPresses: + "showKeyPresses" in inputOptions && inputOptions.showKeyPresses === true, + showMousePresses: + "showMousePresses" in inputOptions && inputOptions.showMousePresses === true, + } + : DEFAULT_RECORDING_INPUT_OPTIONS; + recordingCursor ??= installRecordingCursor(document, window, options, (input) => + ipcRenderer.send(RECORDING_INPUT_CHANNEL, input), + ); + recordingCursor.setTheme(annotationTheme); + if (controller === "agent" || controller === "human" || controller === "none") + recordingCursor.setController(controller); + } else { + recordingCursor?.dispose(); + recordingCursor = null; + } + }, +); +ipcRenderer.on(RECORDING_CONTROLLER_CHANNEL, (_event, controller: unknown, point: unknown) => { + const humanPoint = + typeof point === "object" && + point !== null && + "x" in point && + typeof point.x === "number" && + Number.isFinite(point.x) && + "y" in point && + typeof point.y === "number" && + Number.isFinite(point.y) + ? { x: point.x, y: point.y } + : undefined; + if (controller === "agent" || controller === "human" || controller === "none") + recordingCursor?.setController(controller, humanPoint); +}); +ipcRenderer.on(RECORDING_KEY_CHANNEL, (_event, input: unknown) => { + if ( + typeof input !== "object" || + input === null || + !("key" in input) || + typeof input.key !== "string" + ) + return; + recordingCursor?.keyPress({ + key: input.key, + metaKey: "metaKey" in input && input.metaKey === true, + ctrlKey: "ctrlKey" in input && input.ctrlKey === true, + altKey: "altKey" in input && input.altKey === true, + shiftKey: "shiftKey" in input && input.shiftKey === true, + }); +}); +ipcRenderer.on(RECORDING_POINTER_CHANNEL, (_event, point: unknown) => { + if ( + typeof point === "object" && + point !== null && + "x" in point && + typeof point.x === "number" && + Number.isFinite(point.x) && + "y" in point && + typeof point.y === "number" && + Number.isFinite(point.y) + ) + recordingCursor?.move( + { x: point.x, y: point.y }, + "phase" in point && point.phase === "click" ? "click" : "move", + ); +}); + type AnnotationTool = "select" | "marquee" | "draw" | "erase"; interface SelectedElement { @@ -1361,6 +1442,7 @@ ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme }); ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { annotationTheme = theme; + recordingCursor?.setTheme(theme); activeSession?.applyTheme(theme); }); ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); diff --git a/apps/desktop/src/preview/RecordingCursor.ts b/apps/desktop/src/preview/RecordingCursor.ts new file mode 100644 index 000000000000..70b0a43be43a --- /dev/null +++ b/apps/desktop/src/preview/RecordingCursor.ts @@ -0,0 +1,197 @@ +import type { + DesktopPreviewAnnotationTheme, + DesktopPreviewRecordingInput, +} from "@t3tools/contracts"; + +import { + DEFAULT_RECORDING_INPUT_OPTIONS, + recordingKeyLabel, + recordingKeysAreSensitive, + type RecordingInputOptions, + type RecordingKeyPress, +} from "./RecordingInput.ts"; + +/** + * Chromium's capture cursor uses native window bounds, which do not follow a + * webview's CSS placement or scale. Draw it in the guest's coordinate space + * while recording, and make the native cursor transparent to avoid two cursors. + */ +export function installRecordingCursor( + document: Document, + window: Window, + options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS, + emit: (input: DesktopPreviewRecordingInput) => void = () => {}, +) { + const style = document.createElement("style"); + style.textContent = + "html, html * { cursor: none !important; } @media (prefers-reduced-motion: reduce) { [data-t3code-recording-agent-cursor] { transition: none !important; } }"; + const cursor = document.createElement("div"); + cursor.setAttribute("aria-hidden", "true"); + cursor.setAttribute("data-t3code-recording-cursor", ""); + cursor.style.cssText = + "position:fixed;left:0;top:0;width:16px;height:24px;pointer-events:none;z-index:2147483647;display:none;"; + cursor.innerHTML = + ''; + const agentCursor = document.createElement("div"); + agentCursor.setAttribute("aria-hidden", "true"); + agentCursor.setAttribute("data-t3code-recording-agent-cursor", ""); + agentCursor.style.cssText = + "position:fixed;left:0;top:0;width:20px;height:20px;pointer-events:none;z-index:2147483647;display:none;filter:drop-shadow(0 1px 2px #0003);transition:transform 150ms ease-out,opacity 150ms ease-out;"; + // Match the MousePointer2 icon used by the live AgentBrowserCursor. + agentCursor.innerHTML = + ''; + document.documentElement.append(style, cursor, agentCursor); + let controller: "human" | "agent" | "none" = "none"; + let humanPoint: { readonly x: number; readonly y: number } | null = null; + const drawHuman = () => { + if (!humanPoint) return; + cursor.style.transform = `translate(${humanPoint.x}px, ${humanPoint.y}px)`; + cursor.style.display = "block"; + }; + let agentActive = false; + let agentTimer: number | undefined; + const setController = ( + next: typeof controller, + point?: { readonly x: number; readonly y: number }, + ) => { + if (point) humanPoint = point; + controller = next; + if (next === "agent") cursor.style.display = "none"; + if (next === "human") drawHuman(); + if (!agentActive) agentCursor.style.opacity = next === "human" ? "0.18" : "0.35"; + }; + const setTheme = ( + theme: Pick | null, + ) => { + agentCursor.style.setProperty("--recording-cursor-primary", theme?.primary ?? "#2563eb"); + agentCursor.style.setProperty("--recording-cursor-background", theme?.background ?? "white"); + }; + let lastKeyLabel: string | null = null; + let pointerHeld = false; + let pointerFrame: number | undefined; + let pendingPointer: DesktopPreviewRecordingInput | undefined; + const cancelPendingPointer = () => { + if (pointerFrame !== undefined) window.cancelAnimationFrame(pointerFrame); + pointerFrame = undefined; + pendingPointer = undefined; + }; + const keyPress = (input: RecordingKeyPress, held = false) => { + if (!options.showKeyPresses) return; + const label = recordingKeysAreSensitive(document) + ? null + : recordingKeyLabel(input, /Mac/.test(window.navigator.platform)); + lastKeyLabel = label; + emit({ type: "key", label, held, width: window.innerWidth }); + }; + const pointer = ( + point: { readonly x: number; readonly y: number }, + phase: "move" | "down" | "up" | "click", + ) => { + if (!options.showMousePresses || (phase === "move" && !pointerHeld)) return; + if (phase === "down") pointerHeld = true; + if (phase === "up") pointerHeld = false; + const input: DesktopPreviewRecordingInput = { + type: "pointer", + phase, + ...point, + width: window.innerWidth, + height: window.innerHeight, + }; + if (phase === "move") { + pendingPointer = input; + pointerFrame ??= window.requestAnimationFrame(() => { + pointerFrame = undefined; + if (pendingPointer) emit(pendingPointer); + pendingPointer = undefined; + }); + } else { + cancelPendingPointer(); + emit(input); + } + }; + const move = ( + point: { readonly x: number; readonly y: number }, + phase: "move" | "click" = "move", + ) => { + agentCursor.style.transform = `translate(${point.x}px, ${point.y}px)`; + agentCursor.style.display = "block"; + agentCursor.style.opacity = "1"; + agentActive = true; + window.clearTimeout(agentTimer); + agentTimer = window.setTimeout(() => { + agentActive = false; + agentCursor.style.opacity = controller === "human" ? "0.18" : "0.35"; + }, 700); + pointer(point, phase); + }; + const moveHuman = (point: { readonly x: number; readonly y: number }) => { + if (controller === "agent") return; + humanPoint = point; + drawHuman(); + }; + const pointerMove = (event: PointerEvent) => { + if (event.pointerType === "touch") return; + const point = { x: event.clientX, y: event.clientY }; + moveHuman(point); + pointer(point, "move"); + }; + const pointerDown = (event: PointerEvent) => { + if (event.pointerType === "touch") return; + moveHuman({ x: event.clientX, y: event.clientY }); + pointer({ x: event.clientX, y: event.clientY }, "down"); + }; + const pointerUp = (event: PointerEvent) => { + if (event.pointerType !== "touch") pointer({ x: event.clientX, y: event.clientY }, "up"); + }; + const keyDown = (event: KeyboardEvent) => { + if (event.isComposing || event.repeat) return; + keyPress(event, true); + }; + const keyUp = () => { + if (!options.showKeyPresses) return; + emit({ + type: "key", + label: recordingKeysAreSensitive(document) ? null : lastKeyLabel, + held: false, + width: window.innerWidth, + }); + }; + const hide = () => { + cursor.style.display = "none"; + pointerHeld = false; + cancelPendingPointer(); + if (options.showKeyPresses || options.showMousePresses) emit({ type: "clear" }); + }; + const leave = (event: PointerEvent) => { + if (event.relatedTarget === null) hide(); + }; + window.addEventListener("pointermove", pointerMove, true); + window.addEventListener("pointerdown", pointerDown, true); + window.addEventListener("pointerup", pointerUp, true); + window.addEventListener("pointercancel", pointerUp, true); + window.addEventListener("keydown", keyDown, true); + window.addEventListener("keyup", keyUp, true); + window.addEventListener("pointerout", leave, true); + window.addEventListener("blur", hide); + return { + move, + keyPress, + setController, + setTheme, + dispose: () => { + window.removeEventListener("pointermove", pointerMove, true); + window.removeEventListener("pointerdown", pointerDown, true); + window.removeEventListener("pointerup", pointerUp, true); + window.removeEventListener("pointercancel", pointerUp, true); + window.removeEventListener("keydown", keyDown, true); + window.removeEventListener("keyup", keyUp, true); + window.removeEventListener("pointerout", leave, true); + window.removeEventListener("blur", hide); + cancelPendingPointer(); + window.clearTimeout(agentTimer); + cursor.remove(); + agentCursor.remove(); + style.remove(); + }, + }; +} diff --git a/apps/desktop/src/preview/RecordingInput.test.ts b/apps/desktop/src/preview/RecordingInput.test.ts new file mode 100644 index 000000000000..062685ec2934 --- /dev/null +++ b/apps/desktop/src/preview/RecordingInput.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vite-plus/test"; +import { recordingKeyLabel, recordingKeysAreSensitive } from "./RecordingInput.ts"; + +const key = ( + value: string, + modifiers: Partial<{ + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; + }> = {}, +) => ({ + key: value, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...modifiers, +}); + +describe("recording key labels", () => { + it("formats macOS and other-platform shortcuts", () => { + expect(recordingKeyLabel(key("c", { metaKey: true }), true)).toBe("⌘C"); + expect(recordingKeyLabel(key("c", { ctrlKey: true }), false)).toBe("Ctrl + C"); + expect(recordingKeyLabel(key("Tab", { altKey: true, shiftKey: true }), true)).toBe("⌥⇧⇥"); + }); + it("shows held modifiers once and labels navigation keys", () => { + expect(recordingKeyLabel(key("Meta", { metaKey: true }), true)).toBe("⌘"); + expect(recordingKeyLabel(key("Shift", { shiftKey: true }), false)).toBe("Shift"); + expect(recordingKeyLabel(key("ArrowLeft"), true)).toBe("←"); + expect(recordingKeyLabel(key(" "), false)).toBe("Space"); + }); + it.each(["Dead", "Unidentified", "Process", ""])("excludes composition key %s", (value) => { + expect(recordingKeyLabel(key(value), true)).toBeNull(); + }); +}); + +describe("recording key privacy", () => { + const field = (type: string) => ({ tagName: "INPUT", getAttribute: () => type }); + const sensitive = (activeElement: unknown) => + recordingKeysAreSensitive({ activeElement } as Document); + it("excludes password fields and their shadow-root focus", () => { + expect(sensitive(field("password"))).toBe(true); + expect(sensitive({ shadowRoot: { activeElement: field("password") } })).toBe(true); + expect(sensitive(field("text"))).toBe(false); + }); + it("excludes iframe focus whose field cannot be inspected", () => { + expect(sensitive({ tagName: "IFRAME" })).toBe(true); + expect(sensitive({ tagName: "SECRET-FIELD" })).toBe(true); + }); +}); diff --git a/apps/desktop/src/preview/RecordingInput.ts b/apps/desktop/src/preview/RecordingInput.ts new file mode 100644 index 000000000000..a69102121d4e --- /dev/null +++ b/apps/desktop/src/preview/RecordingInput.ts @@ -0,0 +1,58 @@ +export interface RecordingInputOptions { + readonly showKeyPresses: boolean; + readonly showMousePresses: boolean; +} + +export const DEFAULT_RECORDING_INPUT_OPTIONS: RecordingInputOptions = { + showKeyPresses: false, + showMousePresses: false, +}; + +export interface RecordingKeyPress { + readonly key: string; + readonly metaKey: boolean; + readonly ctrlKey: boolean; + readonly altKey: boolean; + readonly shiftKey: boolean; +} + +/** Formats a single chord without duplicating a modifier pressed on its own. */ +export function recordingKeyLabel(input: RecordingKeyPress, isMac: boolean): string | null { + if (["Dead", "Process", "Unidentified", ""].includes(input.key)) return null; + const modifiers = [ + input.ctrlKey || input.key === "Control" ? (isMac ? "⌃" : "Ctrl") : null, + input.altKey || input.key === "Alt" ? (isMac ? "⌥" : "Alt") : null, + input.shiftKey || input.key === "Shift" ? (isMac ? "⇧" : "Shift") : null, + input.metaKey || input.key === "Meta" ? (isMac ? "⌘" : "Win") : null, + ].filter((value) => value !== null); + const labels: Record = { + Enter: "↵", + Tab: "⇥", + Backspace: "⌫", + Delete: "⌦", + Escape: "Esc", + ArrowUp: "↑", + ArrowDown: "↓", + ArrowLeft: "←", + ArrowRight: "→", + " ": "Space", + Space: "Space", + }; + if (!["Control", "Alt", "Shift", "Meta"].includes(input.key)) { + modifiers.push( + labels[input.key] ?? (input.key.length === 1 ? input.key.toUpperCase() : input.key), + ); + } + return modifiers.join(isMac ? "" : " + "); +} + +/** Unknown iframe or closed-shadow focus is excluded because its field type cannot be checked. */ +export function recordingKeysAreSensitive(document: Document): boolean { + let element = document.activeElement; + while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement; + return ( + element?.tagName === "IFRAME" || + element?.tagName.includes("-") === true || + element?.getAttribute("type")?.toLowerCase() === "password" + ); +} diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 11fb377145e3..aef812ce354b 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -214,7 +214,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.2.1", + version: "1.3.0", runtimeVersion: { // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay diff --git a/apps/mobile/modules/t3-review-diff/android/build.gradle b/apps/mobile/modules/t3-review-diff/android/build.gradle index 22bb070b3b81..d360d1580f1d 100644 --- a/apps/mobile/modules/t3-review-diff/android/build.gradle +++ b/apps/mobile/modules/t3-review-diff/android/build.gradle @@ -8,6 +8,10 @@ android { namespace 'expo.modules.t3reviewdiff' compileSdk rootProject.ext.compileSdkVersion + testOptions { + unitTests.includeAndroidResources = true + } + defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion @@ -16,4 +20,12 @@ android { dependencies { implementation project(':expo-modules-core') + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} + +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } } diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt index 6782e6894d99..80d0410643ff 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt @@ -9,6 +9,7 @@ import android.graphics.Path import android.graphics.RectF import android.graphics.Shader import android.graphics.Typeface +import android.text.TextPaint import kotlin.math.max import kotlin.math.min @@ -177,6 +178,39 @@ internal class ReviewDiffCanvasDrawing(context: Context) { textPaint.isUnderlineText = fontStyle and 4 != 0 } + var codeLayouts = CodeLayoutCache() + + /** Capture paint on the UI thread; the decode worker owns the new cache until publication. */ + fun prepareRows( + tokens: Map>, + style: DiffStyle, + width: Int + ): (List) -> CodeLayoutCache { + configureCodePaint(theme.text, 0, style) + val paint = TextPaint(textPaint) + val colors = theme + val cache = codeLayouts.copyForPreparation() + val availableWidth = ( + width - style.changeBarWidthPx - style.gutterWidthPx - + style.codePaddingPx * 2f + ).toInt() + return { rows -> + cache.apply { layout(rows, tokens, paint, style, colors, availableWidth) } + } + } + + fun codeWrapLayout( + rows: List, + tokens: Map>, + style: DiffStyle, + width: Int + ): CodeWrapLayout { + configureCodePaint(theme.text, 0, style) + val availableWidth = width - style.changeBarWidthPx - style.gutterWidthPx - + style.codePaddingPx * 2f + return codeLayouts.layout(rows, tokens, textPaint, style, theme, availableWidth.toInt()) + } + fun lineNumberColor(change: String): Int = when (change) { "add" -> theme.addText "delete" -> theme.deleteText @@ -198,13 +232,17 @@ internal class ReviewDiffCanvasDrawing(context: Context) { } } + /** Highlights word diffs; [top]..[bottom] is the row's first visual line. */ + @Suppress("LongParameterList") fun drawWordDiffRanges( canvas: Canvas, row: DiffRow, codeX: Float, top: Int, - bottom: Int + bottom: Int, + lines: CodeLines ) { + if (lines.nativeLayout != null) return if (row.wordDiffRanges.isEmpty() || (row.change != "add" && row.change != "delete")) return val color = if (row.change == "add") theme.addBar else theme.deleteBar backgroundPaint.color = withAlpha(color, 71) @@ -213,14 +251,66 @@ internal class ReviewDiffCanvasDrawing(context: Context) { val highlightHeight = max(4f * density, min(bottom - top - 4f * density, fontHeight)) val highlightTop = (top + bottom - highlightHeight) / 2f row.wordDiffRanges.forEach { range -> - val left = codeX + range.start * characterWidth - val right = max(left + 2f * density, codeX + range.end * characterWidth) - canvas.drawRoundRect( - RectF(left, highlightTop, right, highlightTop + highlightHeight), - 3f * density, - 3f * density, - backgroundPaint, - ) + // A wrapped row splits the highlight at each visual line boundary. + lines.starts.forEachIndexed { line, lineStart -> + val start = max(range.start, lineStart) + val end = min(range.end, lines.end(line, Int.MAX_VALUE)) + if (end <= start) return@forEachIndexed + val left = codeX + (start - lineStart) * characterWidth + val right = max(left + 2f * density, left + (end - start) * characterWidth) + val lineTop = highlightTop + line * lines.height + canvas.drawRoundRect( + RectF(left, lineTop, right, lineTop + highlightHeight), + 3f * density, + 3f * density, + backgroundPaint, + ) + } + } + } + + /** Draws a code row's text, or its syntax [tokens] when present, one visual line per start. */ + @Suppress("LongParameterList") + fun drawCode( + canvas: Canvas, + content: String, + tokens: List?, + codeX: Float, + baseline: Float, + style: DiffStyle, + lines: CodeLines + ) { + val nativeLayout = lines.nativeLayout + if (nativeLayout != null) { + canvas.save() + canvas.translate(codeX, baseline - nativeLayout.getLineBaseline(0)) + nativeLayout.draw(canvas) + canvas.restore() + return + } + val runs = if (tokens.isNullOrEmpty()) listOf(DiffToken(content, null, 0)) else tokens + var line = 0 + var x = codeX + var column = 0 + runs.forEach { run -> + configureCodePaint(run.color ?: theme.text, run.fontStyle, style) + var start = 0 + while (start < run.content.length) { + while (line + 1 < lines.starts.size && lines.starts[line + 1] <= column + start) { + line += 1 + x = codeX + } + val end = min(run.content.length, lines.end(line, Int.MAX_VALUE) - column) + val lineBaseline = baseline + line * lines.height + if (lineBaseline + textPaint.fontMetrics.descent >= canvas.clipBounds.top && + lineBaseline + textPaint.fontMetrics.ascent <= canvas.clipBounds.bottom + ) { + canvas.drawText(run.content, start, end, x, lineBaseline, textPaint) + x += textPaint.measureText(run.content, start, end) + } + start = end + } + column += run.content.length } } diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt new file mode 100644 index 000000000000..dc77a8467033 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt @@ -0,0 +1,169 @@ +package expo.modules.t3reviewdiff + +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.style.BackgroundColorSpan +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import kotlin.math.ceil +import kotlin.math.max + +/** Text layout is independent of comment heights and vertical row offsets. */ +internal class CodeLines( + val starts: IntArray, + val height: Int, + val nativeLayout: StaticLayout? = null +) { + fun end(line: Int, length: Int): Int = if (line + 1 < starts.size) starts[line + 1] else length + + fun firstHeight(base: Int): Int = max(base, nativeLayout?.getLineBottom(0) ?: 0) + + fun baseline(top: Int, bottom: Int, paint: Paint): Float = nativeLayout?.let { + top + (bottom - top - it.getLineBottom(0)) / 2f + it.getLineBaseline(0) + } ?: ((top + bottom - paint.fontMetrics.ascent - paint.fontMetrics.descent) / 2f) + + val extraHeight: Int + get() = nativeLayout?.let { it.height - it.getLineBottom(0) } ?: ((starts.size - 1) * height) +} + +internal class CodeWrapLayout( + val enabled: Boolean, + private val linesByRowId: Map +) { + fun lines(rowId: String): CodeLines = linesByRowId[rowId] ?: SINGLE_LINE + fun extraHeight(rowId: String): Int = lines(rowId).extraHeight + fun rowHeight(rowId: String, base: Int): Int = lines(rowId).let { + it.firstHeight(base) + + it.extraHeight + } + + companion object { + private val SINGLE_LINE = CodeLines(intArrayOf(0), 0) + val NONE = CodeWrapLayout(false, emptyMap()) + } +} + +/** ASCII is fixed-pitch; other text needs the same shaping for measurement and drawing. */ +internal fun createCodeLines(text: CharSequence, paint: TextPaint, width: Int): CodeLines { + val characterWidth = paint.measureText("M") + val lineHeight = ceil(paint.fontMetrics.run { descent - ascent }).toInt() + if (text.all { it in ' '..'~' }) { + val columns = max(1, (width / characterWidth).toInt()) + return CodeLines( + IntArray(max(1, (text.length + columns - 1) / columns)) { + it * columns + }, + lineHeight + ) + } + val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint, max(1, width)) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setIncludePad(false) + .setBreakStrategy(Layout.BREAK_STRATEGY_SIMPLE) + .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE) + .build() + return CodeLines(IntArray(layout.lineCount) { layout.getLineStart(it) }, lineHeight, layout) +} + +internal class CodeLayoutCache { + private data class Entry(val row: DiffRow, val tokens: List?, val lines: CodeLines) + private var entries = emptyMap() + private var previousStyle: DiffStyle? = null + private var previousTheme: DiffTheme? = null + private var previousWidth = 0 + + /** Entries are immutable; a worker can reuse them without changing the displayed cache. */ + fun copyForPreparation(): CodeLayoutCache = CodeLayoutCache().also { + it.entries = entries + it.previousStyle = previousStyle + it.previousTheme = previousTheme + it.previousWidth = previousWidth + } + + @Suppress("LongParameterList") + fun layout( + rows: List, + tokens: Map>, + paint: Paint, + style: DiffStyle, + theme: DiffTheme, + width: Int + ): CodeWrapLayout { + if (!style.wordWrap || width < paint.measureText("M")) { + entries = emptyMap() + return CodeWrapLayout.NONE + } + if (previousStyle != style || previousTheme != theme || previousWidth != width) { + entries = emptyMap() + previousStyle = style + previousTheme = theme + previousWidth = width + } + val next = HashMap() + val layouts = HashMap() + for (row in rows) { + if (row.kind != "line") continue + val rowTokens = tokens[row.id] + val cached = entries[row.id] + val entry = if (cached?.row == row && cached.tokens == rowTokens) { + cached + } else { + val text = styledCode(row, rowTokens, theme) + Entry(row, rowTokens, createCodeLines(text, TextPaint(paint), width)) + } + next[row.id] = entry + layouts[row.id] = entry.lines + } + entries = next + return CodeWrapLayout(true, layouts) + } + + private fun styledCode(row: DiffRow, tokens: List?, theme: DiffTheme): CharSequence { + // The ASCII path uses the existing token drawing and rounded highlight rectangles. + if (row.content.all { it in ' '..'~' }) return row.content + val text = SpannableString(row.content) + var offset = 0 + for (token in tokens.orEmpty()) { + val end = (offset + token.content.length).coerceAtMost(text.length) + if (end > offset) { + token.color?.let { + text.setSpan(ForegroundColorSpan(it), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + val fontStyle = (if (token.fontStyle and 2 != 0) Typeface.BOLD else 0) or + (if (token.fontStyle and 1 != 0) Typeface.ITALIC else 0) + if (fontStyle != + 0 + ) { + text.setSpan(StyleSpan(fontStyle), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + if (token.fontStyle and 4 != + 0 + ) { + text.setSpan(UnderlineSpan(), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + offset = end + } + if (row.change == "add" || row.change == "delete") { + val bar = if (row.change == "add") theme.addBar else theme.deleteBar + val color = Color.argb(71, Color.red(bar), Color.green(bar), Color.blue(bar)) + for (range in row.wordDiffRanges) { + val start = range.start.coerceIn(0, text.length) + val end = range.end.coerceIn(start, text.length) + if (end > + start + ) { + text.setSpan(BackgroundColorSpan(color), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + } + return text + } +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 97e9f696db90..37fee1cb3613 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -143,10 +143,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont fun setRowsJson(value: String) { rowsDecodeGeneration += 1 val generation = rowsDecodeGeneration + val prepareLayout = canvasView.prepareRows() payloadDecodeExecutor.execute { val decodedRows = parseRows(value) + val codeLayouts = prepareLayout(decodedRows) post { if (generation != rowsDecodeGeneration) return@post + canvasView.useCodeLayouts(codeLayouts) rows = decodedRows lastVisibleFileId = null rebuildVisibleRows() @@ -460,7 +463,7 @@ internal data class DiffWordDiffRange( val end: Int ) -private data class DiffToken( +internal data class DiffToken( val content: String, val color: Int?, val fontStyle: Int @@ -543,6 +546,7 @@ internal data class DiffTheme( } internal data class DiffStyle( + val wordWrap: Boolean, val rowHeightPx: Float, val gutterWidthPx: Float, val codePaddingPx: Float, @@ -564,6 +568,7 @@ internal data class DiffStyle( ) { companion object { fun defaults(density: Float): DiffStyle = DiffStyle( + wordWrap = false, rowHeightPx = 20f * density, gutterWidthPx = 72f * density, codePaddingPx = 10f * density, @@ -587,6 +592,7 @@ internal data class DiffStyle( fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try { val json = JSONObject(value) DiffStyle( + wordWrap = json.optBoolean("wordWrap", fallback.wordWrap), rowHeightPx = json.floatDp("rowHeight", fallback.rowHeightPx, density), gutterWidthPx = json.floatDp("gutterWidth", fallback.gutterWidthPx, density), codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density), @@ -684,6 +690,8 @@ private class DiffCanvasView(context: Context) : View(context) { }, ) private var rowOffsets = intArrayOf(0) + + private var codeWrap = CodeWrapLayout.NONE private var verticalOffset = 0 private var horizontalOffset = 0 private val headerPathOffsetsByFileId = mutableMapOf() @@ -699,6 +707,7 @@ private class DiffCanvasView(context: Context) : View(context) { var tokensByRowId: Map> = emptyMap() set(value) { field = value + if (style.wordWrap) rebuildOffsets() invalidate() } var viewedFileIds: Set = emptySet() @@ -725,6 +734,7 @@ private class DiffCanvasView(context: Context) : View(context) { set(value) { field = value drawing.theme = value + if (style.wordWrap) rebuildOffsets() invalidate() } var style: DiffStyle = DiffStyle.defaults(density) @@ -742,6 +752,11 @@ private class DiffCanvasView(context: Context) : View(context) { var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null + fun prepareRows() = drawing.prepareRows(tokensByRowId, style, width) + fun useCodeLayouts(layouts: CodeLayoutCache) { + drawing.codeLayouts = layouts + } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { setMeasuredDimension( MeasureSpec.getSize(widthMeasureSpec), @@ -751,6 +766,8 @@ private class DiffCanvasView(context: Context) : View(context) { override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { super.onSizeChanged(width, height, oldWidth, oldHeight) + // Wrapped rows take their height from the width, so a new width is a new layout. + if (style.wordWrap && width != oldWidth) layoutRows() setVerticalOffset(verticalOffset) setHorizontalOffset(horizontalOffset) clampHeaderPathOffsets() @@ -818,7 +835,8 @@ private class DiffCanvasView(context: Context) : View(context) { fun horizontalOffset(): Int = horizontalOffset - fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width) + fun maxHorizontalOffset(): Int = + if (codeWrap.enabled) 0 else max(0, contentWidthPx - width) fun maxHorizontalOffset(target: HorizontalPanTarget): Int = if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) { @@ -844,14 +862,20 @@ private class DiffCanvasView(context: Context) : View(context) { } private fun rebuildOffsets() { + layoutRows() + requestLayout() + invalidate() + } + + private fun layoutRows() { + codeWrap = drawing.codeWrapLayout(rows, tokensByRowId, style, width) rowOffsets = IntArray(rows.size + 1) rows.forEachIndexed { index, row -> rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row) } setVerticalOffset(verticalOffset) + setHorizontalOffset(horizontalOffset) clampHeaderPathOffsets() - requestLayout() - invalidate() } private fun rowHeight(row: DiffRow): Int = when (row.kind) { @@ -862,6 +886,7 @@ private class DiffCanvasView(context: Context) : View(context) { } else { (124 * density).toInt() } + "line" -> codeWrap.rowHeight(row.id, style.rowHeightPx.toInt()) else -> style.rowHeightPx.toInt() }.coerceAtLeast(1) @@ -1191,23 +1216,17 @@ private class DiffCanvasView(context: Context) : View(context) { ) } - val tokens = tokensByRowId[row.id] + // Wrapped rows keep the line number and first code line in the first row-height band. + val lines = codeWrap.lines(row.id) + val firstLineBottom = top + lines.firstHeight(style.rowHeightPx.toInt()) drawScrollableCode(canvas, top, bottom) { codeX -> drawing.configureCodePaint(theme.text, 0, style) - drawing.drawWordDiffRanges(canvas, row, codeX, top, bottom) - if (tokens.isNullOrEmpty()) { - canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint) - } else { - var x = codeX - tokens.forEach { token -> - drawing.configureCodePaint(token.color ?: theme.text, token.fontStyle, style) - canvas.drawText(token.content, x, centeredBaseline(top, bottom, textPaint), textPaint) - x += textPaint.measureText(token.content) - } - } + drawing.drawWordDiffRanges(canvas, row, codeX, top, firstLineBottom, lines) + val baseline = lines.baseline(top, firstLineBottom, textPaint) + drawing.drawCode(canvas, row.content, tokensByRowId[row.id], codeX, baseline, style, lines) } - drawLineNumber(canvas, row, top, bottom) + drawLineNumber(canvas, row, top, firstLineBottom) } private fun drawLineNumber(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { diff --git a/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt new file mode 100644 index 000000000000..1f4234b7175f --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt @@ -0,0 +1,150 @@ +package expo.modules.t3reviewdiff + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Typeface +import android.text.Spanned +import android.text.TextPaint +import android.text.style.BackgroundColorSpan +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], manifest = Config.NONE) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ReviewDiffCodeLayoutTest { + private val paint = TextPaint().apply { + color = Color.WHITE + textSize = 24f + typeface = Typeface.MONOSPACE + } + + @Test + fun unicodeAndTabsFitWithoutSplittingClusters() { + val fixtures = listOf("漢字表示", "e\u0301", "👨‍👩‍👧‍👦", "مرحبا بالعالم ", "\tvalue ") + for (fixture in fixtures) { + val text = fixture.repeat(40) + for (width in listOf(180, 280, 420)) { + val layout = requireNotNull(createCodeLines(text, paint, width).nativeLayout) + assertInkFits(layout, width, fixture) + assertLinesFit(layout, fixture, width) + } + } + } + + private fun assertLinesFit(layout: android.text.StaticLayout, fixture: String, width: Int) { + val text = layout.text + for (line in 0 until layout.lineCount) { + if (!fixture.contains('\t')) { + assertTrue("$fixture line $line at $width", layout.getLineMax(line) <= width + 1) + } + val start = layout.getLineStart(line) + assertTrue(start == 0 || !Character.isLowSurrogate(text[start])) + if (fixture == "👨‍👩‍👧‍👦" || fixture == "e\u0301") { + assertEquals(0, start % fixture.length) + } + } + } + + private fun assertInkFits(layout: android.text.StaticLayout, width: Int, fixture: String) { + val bitmap = Bitmap.createBitmap(width + 40, layout.height, Bitmap.Config.ARGB_8888) + layout.draw(Canvas(bitmap)) + for (x in width + 1 until bitmap.width) { + for (y in 0 until bitmap.height) { + assertEquals("$fixture ink outside width $width", 0, Color.alpha(bitmap.getPixel(x, y))) + } + } + bitmap.recycle() + } + + @Test + fun asciiSegmentsCoverTheWholeLineAndFit() { + val text = "const value = 123; ".repeat(100) + val lines = createCodeLines(text, paint, 280) + val pieces = lines.starts.indices.map { + text.substring(lines.starts[it], lines.end(it, text.length)) + } + assertEquals(text, pieces.joinToString("")) + assertTrue(pieces.all { paint.measureText(it) <= 280 }) + } + + @Test + fun changingCommentHeightReusesCodeButWidthAndContentInvalidateIt() { + val cache = CodeLayoutCache() + val row = row("漢字".repeat(100)) + val comment = row.copy(kind = "comment", id = "comment", content = "", commentText = "Before") + val style = DiffStyle.defaults(1f).copy(wordWrap = true) + val theme = DiffTheme.fallback("light") + val first = cache.layout( + listOf(row, comment), + emptyMap(), + paint, + style, + theme, + 280 + ).lines(row.id) + val second = cache.layout( + listOf(row, comment.copy(commentText = "After")), + emptyMap(), + paint, + style, + theme, + 280, + ).lines(row.id) + assertSame(first, second) + val narrow = cache.layout(listOf(row), emptyMap(), paint, style, theme, 180).lines(row.id) + assertNotSame(first, narrow) + assertTrue(narrow.extraHeight > first.extraHeight) + val edited = cache.layout( + listOf(row.copy(content = "短い")), + emptyMap(), + paint, + style, + theme, + 180 + ).lines(row.id) + assertTrue(edited.extraHeight < narrow.extraHeight) + assertEquals( + 0, + cache.layout( + listOf(row), + emptyMap(), + paint, + style.copy(wordWrap = false), + theme, + 180 + ).extraHeight(row.id) + ) + } + + @Test + fun highlightsUseNativeTextRangesAndSurviveSyntaxArrival() { + val cache = CodeLayoutCache() + val row = row("漢字".repeat(30)).copy(wordDiffRanges = listOf(DiffWordDiffRange(3, 21))) + val style = DiffStyle.defaults(1f).copy(wordWrap = true) + val theme = DiffTheme.fallback("light") + val initial = cache.layout(listOf(row), emptyMap(), paint, style, theme, 180).lines(row.id) + val tokens = mapOf(row.id to listOf(DiffToken(row.content, 0xff008800.toInt(), 2))) + val highlighted = cache.layout(listOf(row), tokens, paint, style, theme, 180).lines(row.id) + assertNotSame(initial, highlighted) + val text = requireNotNull(highlighted.nativeLayout).text as Spanned + val span = text.getSpans(0, text.length, BackgroundColorSpan::class.java).single() + assertEquals(3, text.getSpanStart(span)) + assertEquals(21, text.getSpanEnd(span)) + } + + private fun row(content: String) = DiffRow( + kind = "line", id = "line", fileId = "file", filePath = "test.ts", previousPath = null, + changeType = "modified", additions = 1, deletions = 0, text = "", content = content, + change = "add", oldLineNumber = null, newLineNumber = 1, wordDiffRanges = emptyList(), + commentText = "", commentRangeLabel = "", commentSectionTitle = "", + ) +} diff --git a/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift b/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift new file mode 100644 index 000000000000..1d8b3e8ea8d9 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift @@ -0,0 +1,123 @@ +import UIKit + +/// ASCII uses fixed-pitch columns. TextKit handles shaping, tabs, and Unicode highlights. +final class ReviewDiffCodeLayout: NSObject { + // Measurement reuses one engine; only recently drawn rows retain a full TextKit layout. + private static var measurer: ReviewDiffTextLayout { + let key = "T3ReviewDiff.textMeasurer" + if let layout = Thread.current.threadDictionary[key] as? ReviewDiffTextLayout { return layout } + let layout = ReviewDiffTextLayout() + Thread.current.threadDictionary[key] = layout + return layout + } + private static let drawnLayouts: NSCache = { + let cache = NSCache() + cache.countLimit = 128 + return cache + }() + let text: String + let starts: [Int] + let lineHeight: CGFloat + let firstLineHeight: CGFloat + let extraHeight: CGFloat + private let font: UIFont + private let width: CGFloat + private let characterWidth: CGFloat + let usesNativeLayout: Bool + + init(text: String, font: UIFont, width: CGFloat, characterWidth: CGFloat) { + self.text = text + self.font = font + self.width = width + self.characterWidth = characterWidth + lineHeight = ceil(font.lineHeight) + if text.utf8.allSatisfy({ $0 >= 32 && $0 <= 126 }) { + let columns = max(1, Int(width / characterWidth)) + starts = Array(stride(from: 0, to: max(1, text.utf8.count), by: columns)) + firstLineHeight = font.lineHeight + extraHeight = CGFloat(starts.count - 1) * lineHeight + usesNativeLayout = false + } else { + let layout = Self.measurer + layout.configure(text: text, font: font, width: width, characterWidth: characterWidth) + let manager = layout.manager + let container = layout.container + usesNativeLayout = true + starts = [0] + firstLineHeight = manager.numberOfGlyphs > 0 + ? manager.lineFragmentRect(forGlyphAt: 0, effectiveRange: nil).height : font.lineHeight + extraHeight = max(0, manager.usedRect(for: container).height - firstLineHeight) + } + } + + private func nativeLayout() -> ReviewDiffTextLayout { + if let cached = Self.drawnLayouts.object(forKey: self) { return cached } + let layout = ReviewDiffTextLayout() + layout.configure(text: text, font: font, width: width, characterWidth: characterWidth) + Self.drawnLayouts.setObject(layout, forKey: self) + return layout + } + + /// Only colors change when syntax tokens arrive; the measured text and font stay intact. + func decorate(text: NSAttributedString, highlights: [NSRange], color: UIColor, version: Int) { + guard usesNativeLayout else { return } + let layout = nativeLayout() + guard layout.decorationVersion != version else { return } + let storage = layout.storage + let fullRange = NSRange(location: 0, length: storage.length) + storage.beginEditing() + storage.removeAttribute(.foregroundColor, range: fullRange) + storage.removeAttribute(.backgroundColor, range: fullRange) + text.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: text.length)) { value, range, _ in + let intersection = NSIntersectionRange(range, fullRange) + if let value, intersection.length > 0 { + storage.addAttribute(.foregroundColor, value: value, range: intersection) + } + } + for range in highlights { + let intersection = NSIntersectionRange(range, fullRange) + if intersection.length > 0 { + storage.addAttribute(.backgroundColor, value: color, range: intersection) + } + } + storage.endEditing() + layout.decorationVersion = version + } + + func draw(at origin: CGPoint, clip: CGRect) { + guard usesNativeLayout else { return } + let layout = nativeLayout() + let manager = layout.manager + let container = layout.container + let visible = clip.offsetBy(dx: -origin.x, dy: -origin.y) + let range = manager.glyphRange(forBoundingRect: visible, in: container) + manager.drawBackground(forGlyphRange: range, at: origin) + manager.drawGlyphs(forGlyphRange: range, at: origin) + } +} + +private final class ReviewDiffTextLayout { + let storage = NSTextStorage() + let manager = NSLayoutManager() + let container = NSTextContainer(size: .zero) + var decorationVersion = -1 + + init() { + container.lineFragmentPadding = 0 + container.lineBreakMode = .byCharWrapping + manager.addTextContainer(container) + storage.addLayoutManager(manager) + } + + func configure(text: String, font: UIFont, width: CGFloat, characterWidth: CGFloat) { + let paragraph = NSMutableParagraphStyle() + paragraph.lineBreakMode = .byCharWrapping + paragraph.tabStops = [] + paragraph.defaultTabInterval = characterWidth * 4 + container.size = CGSize(width: max(1, width), height: .greatestFiniteMagnitude) + storage.setAttributedString(NSAttributedString(string: text, attributes: [ + .font: font, .ligature: 0, .paragraphStyle: paragraph, + ])) + manager.ensureLayout(for: container) + } +} diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index 74111988f150..e2400e0a9393 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -137,6 +137,7 @@ private struct ReviewDiffNativeTheme { } private struct ReviewDiffNativeStylePayload: Decodable { + let wordWrap: Bool? let rowHeight: Double? let contentWidth: Double? let changeBarWidth: Double? @@ -170,6 +171,7 @@ private struct ReviewDiffNativeStylePayload: Decodable { } private struct ReviewDiffNativeStyle { + let wordWrap: Bool let rowHeight: CGFloat let contentWidth: CGFloat let changeBarWidth: CGFloat @@ -203,6 +205,7 @@ private struct ReviewDiffNativeStyle { static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle { ReviewDiffNativeStyle( + wordWrap: payload?.wordWrap ?? false, rowHeight: metric(payload?.rowHeight, fallback: 24), contentWidth: metric(payload?.contentWidth, fallback: 2800), changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4), @@ -277,6 +280,7 @@ private struct ReviewDiffNativeStyle { func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle { ReviewDiffNativeStyle( + wordWrap: wordWrap, rowHeight: rowHeight ?? self.rowHeight, contentWidth: contentWidth ?? self.contentWidth, changeBarWidth: changeBarWidth, @@ -434,16 +438,21 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { guard let self, generation == self.rowsDecodeGeneration else { return } - self.rows = decodedRows - self.contentView.rows = decodedRows - self.hasAppliedInitialRowIndex = false - self.lastVisibleFileId = nil - self.emitDebug("rows-decoded", [ - "rows": decodedRows.count, - "firstKind": decodedRows.first?.kind ?? "none", - ]) - self.updateContentMetrics() - self.applyPendingScrollIfNeeded() + self.contentView.prepareRows(decodedRows, on: self.payloadDecodeQueue, isCurrent: { [weak self] in + generation == self?.rowsDecodeGeneration + }, completion: { [weak self] in + guard let self, generation == self.rowsDecodeGeneration else { return } + self.rows = decodedRows + self.contentView.rows = decodedRows + self.hasAppliedInitialRowIndex = false + self.lastVisibleFileId = nil + self.emitDebug("rows-decoded", [ + "rows": decodedRows.count, + "firstKind": decodedRows.first?.kind ?? "none", + ]) + self.updateContentMetrics() + self.applyPendingScrollIfNeeded() + }) } } catch { let message = error.localizedDescription @@ -652,6 +661,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { private func updateContentMetrics() { let style = contentView.style + contentView.viewportWidth = bounds.width let height = max(bounds.height, contentView.contentHeight) let width = bounds.width scrollView.contentSize = CGSize(width: bounds.width, height: height) @@ -661,7 +671,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { width: max(width, 1), height: max(bounds.height, 1) ) - contentView.viewportWidth = bounds.width contentView.verticalOffset = scrollView.contentOffset.y contentView.invalidateVisibleViewport() contentView.setNeedsDisplay() @@ -929,6 +938,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { headerPathOffsetsByFileId.removeAll() activePanFileId = nil activePanKind = nil + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() rebuildRowLayout() setNeedsDisplayForVisibleBounds() @@ -936,6 +946,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] { didSet { + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() clampHorizontalOffsets() setNeedsDisplayForVisibleBounds() @@ -976,6 +987,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } var style = ReviewDiffNativeStyle.resolve(nil) { didSet { + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() rebuildRowLayout() clampHorizontalOffsets() @@ -984,6 +996,10 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } var viewportWidth: CGFloat = 0 { didSet { + // Wrapped rows take their height from the width, so a new width is a new layout. + if style.wordWrap, viewportWidth != oldValue { + rebuildRowLayout() + } clampHorizontalOffsets() setNeedsDisplayForVisibleBounds() } @@ -992,6 +1008,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { var theme = ReviewDiffNativeTheme.resolve("light") { didSet { tokenColorsByHex.removeAll() + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() setNeedsDisplayForVisibleBounds() } @@ -1004,6 +1021,13 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { private var tokenColorsByHex: [String: UIColor] = [:] private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:] private var codeCharacterWidth: CGFloat = 8 + /// Columns per visual line while word wrap is on; nil while code rows pan horizontally. + private var codeWrapColumns: Int? + /// Text geometry survives comment height changes; width, font, and content invalidate it. + private var codeLayoutsByRowId: [String: ReviewDiffCodeLayout] = [:] + private var codeLayoutWidth: CGFloat = 0 + private var codeLayoutFont: UIFont? + private var codeDecorationVersion = 0 private var panStartHorizontalOffset: CGFloat = 0 private var activePanFileId: String? private var activePanKind: ReviewDiffHorizontalPanKind? @@ -1029,6 +1053,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { stickyWidth + style.codePadding } + /// Height before word wrap. Laid-out rows use height(at:), which includes wrapped lines. private func height(for row: ReviewDiffNativeRow) -> CGFloat { if row.kind == "file" { return style.fileHeaderHeight @@ -1045,6 +1070,16 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return style.rowHeight } + /// Laid-out height, including wrapped lines. Requires a layout built from the current rows. + private func height(at index: Int) -> CGFloat { + let nextOffset = index + 1 < rowOffsets.count ? rowOffsets[index + 1] : contentHeight + return nextOffset - rowOffsets[index] + } + + private var codeWrapLineHeight: CGFloat { + ceil(codeFont.lineHeight) + } + func frameForRow(at index: Int) -> CGRect? { guard rows.indices.contains(index), rowOffsets.indices.contains(index) else { return nil @@ -1054,43 +1089,110 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { x: 0, y: rowOffsets[index], width: max(viewportWidth, 1), - height: height(for: rows[index]) + height: height(at: index) ) } + /// Shape new content on the existing decode worker before publishing rows to the UI. + func prepareRows( + _ rows: [ReviewDiffNativeRow], + on queue: DispatchQueue, + isCurrent: @escaping () -> Bool, + completion: @escaping () -> Void + ) { + let font = codeFont + let width = viewportWidth - codeStartX - style.codePadding + let characterWidth = monospaceCharacterWidth(font: font) + guard style.wordWrap, width >= characterWidth, characterWidth > 0 else { + completion() + return + } + let cached = codeLayoutWidth == width && codeLayoutFont == font ? codeLayoutsByRowId : [:] + queue.async { [weak self] in + var layouts: [String: ReviewDiffCodeLayout] = [:] + for row in rows where row.kind == "line" { + guard let text = row.content else { continue } + if let previous = cached[row.id], previous.text == text { + layouts[row.id] = previous + } else { + layouts[row.id] = ReviewDiffCodeLayout(text: text, font: font, width: width, characterWidth: characterWidth) + } + } + DispatchQueue.main.async { [weak self] in + guard let self, isCurrent() else { return } + if self.codeFont != font || self.viewportWidth - self.codeStartX - self.style.codePadding != width { + self.prepareRows(rows, on: queue, isCurrent: isCurrent, completion: completion) + return + } + self.codeLayoutWidth = width + self.codeLayoutFont = font + self.codeLayoutsByRowId = layouts + completion() + } + } + } + private func rebuildRowLayout() { var nextOffsets: [CGFloat] = [] var nextFileHeaderRowIndices: [Int] = [] nextOffsets.reserveCapacity(rows.count) var maxColumnCountsByFileId: [String: Int] = [:] + var nextCodeLayouts: [String: ReviewDiffCodeLayout] = [:] var offset: CGFloat = 0 + let font = codeFont + let characterWidth = monospaceCharacterWidth(font: font) + let wrapAvailableWidth = viewportWidth - codeStartX - style.codePadding + let wrapColumns = style.wordWrap && characterWidth > 0 && wrapAvailableWidth >= characterWidth + ? Int(wrapAvailableWidth / characterWidth) + : nil + if codeLayoutWidth != wrapAvailableWidth || codeLayoutFont != font { + codeLayoutsByRowId.removeAll() + codeLayoutWidth = wrapAvailableWidth + codeLayoutFont = font + } for (index, row) in rows.enumerated() { nextOffsets.append(offset) if row.kind == "file" { nextFileHeaderRowIndices.append(index) } - offset += height(for: row) + var rowHeight = height(for: row) let fileId = resolvedFileId(for: row) switch row.kind { case "line": - maxColumnCountsByFileId[fileId] = max( - maxColumnCountsByFileId[fileId] ?? 0, - row.content?.count ?? 0 - ) + // UTF-16 columns match the word diff ranges and the segments drawCodeLines draws. + let columnCount = row.content?.utf16.count ?? 0 + maxColumnCountsByFileId[fileId] = max(maxColumnCountsByFileId[fileId] ?? 0, columnCount) + if wrapColumns != nil, let content = row.content { + let cached = codeLayoutsByRowId[row.id] + let layout: ReviewDiffCodeLayout + if let cached, cached.text == content { + layout = cached + } else { + layout = ReviewDiffCodeLayout( + text: content, font: font, width: wrapAvailableWidth, characterWidth: characterWidth + ) + } + nextCodeLayouts[row.id] = layout + if rowHeight > 0 { + rowHeight = max(rowHeight, layout.firstLineHeight) + layout.extraHeight + } + } case "hunk": maxColumnCountsByFileId[fileId] = max( maxColumnCountsByFileId[fileId] ?? 0, - row.text?.count ?? 0 + row.text?.utf16.count ?? 0 ) default: - continue + break } + offset += rowHeight } - let characterWidth = monospaceCharacterWidth(font: codeFont) codeCharacterWidth = characterWidth + codeWrapColumns = wrapColumns + codeLayoutsByRowId = nextCodeLayouts contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2 return max(0, min(style.contentWidth, measuredWidth)) @@ -1498,7 +1600,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { while lowerBound <= upperBound { let midpoint = (lowerBound + upperBound) / 2 let rowStart = rowOffsets[midpoint] - let rowEnd = rowStart + height(for: rows[midpoint]) + let rowEnd = rowStart + height(at: midpoint) if absoluteY < rowStart { upperBound = midpoint - 1 @@ -1521,7 +1623,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { var upperBound = rows.count while lowerBound < upperBound { let midpoint = (lowerBound + upperBound) / 2 - let rowEnd = rowOffsets[midpoint] + height(for: rows[midpoint]) + let rowEnd = rowOffsets[midpoint] + height(at: midpoint) if rowEnd < absoluteY { lowerBound = midpoint + 1 @@ -1609,6 +1711,9 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) { return maxHeaderPathOffset(for: row) } + if codeWrapColumns != nil { + return 0 + } return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX)) } @@ -1728,7 +1833,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { var drawnRowCount = 0 for rowIndex in firstRowIndex...lastRowIndex { let rowStart = rowOffsets[rowIndex] - let rowHeight = height(for: rows[rowIndex]) + let rowHeight = height(at: rowIndex) if rowHeight <= 0 { continue } @@ -1786,7 +1891,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) { let rowY = rowOffsets[rowIndex] - verticalOffset - let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(for: row)) + let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(at: rowIndex)) switch row.kind { case "file": @@ -2172,15 +2277,21 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let horizontalOffset = horizontalOffset(for: fileId) let contentWidth = contentWidth(for: fileId) let change = row.change ?? "context" + // Wrapped rows keep the line number and first code line in the first row-height band. + let layout = codeLayoutsByRowId[row.id] + let firstLineRect = CGRect( + x: rect.minX, y: rect.minY, width: rect.width, + height: max(style.rowHeight, layout?.firstLineHeight ?? 0) + ) rowBackground(for: change).setFill() context.fill(rect) if change == "add" { theme.addBar.setFill() - context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight)) + context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height)) } else if change == "delete" { drawDeleteStripes( - rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight), + rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height), context: context ) } @@ -2193,7 +2304,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { "\(lineNumber)", rect: CGRect( x: style.changeBarWidth, - y: centeredTextY(in: rect, font: lineNumberFont), + y: centeredTextY(in: firstLineRect, font: lineNumberFont), width: style.gutterWidth - style.codePadding, height: lineNumberFont.lineHeight ), @@ -2203,28 +2314,85 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } context.saveGState() - context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight)) + context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: rect.height)) let codeTextRect = CGRect( x: codeStartX - horizontalOffset, - y: centeredTextY(in: rect, font: codeFont), + y: centeredTextY(in: firstLineRect, font: codeFont), width: contentWidth, height: codeFont.lineHeight ) - drawWordDiffRanges(row, rowRect: rect, context: context, horizontalOffset: horizontalOffset) + if let layout, layout.usesNativeLayout { + let text = tokensByRowId[row.id].map { + tokenAttributedString(rowId: row.id, tokens: $0, fallbackColor: theme.text, font: codeFont) + } ?? NSAttributedString(string: row.content ?? "", attributes: [.foregroundColor: theme.text]) + let highlights = (change == "add" || change == "delete") ? (row.wordDiffRanges ?? []) : [] + layout.decorate( + text: text, + highlights: highlights.filter { $0.start >= 0 && $0.end > $0.start }.map { + NSRange(location: $0.start, length: $0.end - $0.start) + }, + color: (change == "add" ? theme.addBar : theme.deleteBar).withAlphaComponent(0.28), + version: codeDecorationVersion + ) + layout.draw( + at: CGPoint(x: codeStartX, y: rect.minY + max(0, (firstLineRect.height - layout.firstLineHeight) / 2)), + clip: context.boundingBoxOfClipPath + ) + context.restoreGState() + return + } + let lineStarts = layout?.starts ?? [0] + drawWordDiffRanges( + row, + lineStarts: lineStarts, + firstLineRect: firstLineRect, + context: context, + horizontalOffset: horizontalOffset + ) if let tokens = tokensByRowId[row.id], !tokens.isEmpty { - drawTokenText( + let attributedText = tokenAttributedString( rowId: row.id, - tokens, - rect: codeTextRect, + tokens: tokens, fallbackColor: theme.text, font: codeFont ) + drawCodeLines(length: attributedText.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in + let segment = range.length == attributedText.length + ? attributedText + : attributedText.attributedSubstring(from: range) + segment.draw(in: lineRect) + } } else { - drawText(row.content ?? "", rect: codeTextRect, color: theme.text, font: codeFont) + let content = (row.content ?? "") as NSString + drawCodeLines(length: content.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in + let segment = range.length == content.length ? content as String : content.substring(with: range) + drawText(segment, rect: lineRect, color: theme.text, font: codeFont) + } } context.restoreGState() } + /// Draws the segment starting at each of the row's line starts on its own visual line. + private func drawCodeLines( + length: Int, + lineStarts: [Int], + firstLineRect: CGRect, + draw: (NSRange, CGRect) -> Void + ) { + var lineRect = firstLineRect + let clip = UIGraphicsGetCurrentContext()?.boundingBoxOfClipPath ?? bounds + let first = max(0, Int(floor((clip.minY - firstLineRect.minY) / codeWrapLineHeight))) + let last = min(lineStarts.count, Int(ceil((clip.maxY - firstLineRect.minY) / codeWrapLineHeight))) + guard first < last else { return } + lineRect.origin.y += CGFloat(first) * codeWrapLineHeight + for line in first.. start else { + continue + } + let highlightRect = CGRect( + x: codeStartX - horizontalOffset + CGFloat(start - lineStart) * codeCharacterWidth, + y: highlightY + CGFloat(line) * codeWrapLineHeight, + width: max(2, CGFloat(end - start) * codeCharacterWidth), + height: highlightHeight + ) + UIBezierPath(roundedRect: highlightRect, cornerRadius: 3).fill() + } } } @@ -2448,22 +2624,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength) } - private func drawTokenText( - rowId: String, - _ tokens: [ReviewDiffNativeToken], - rect: CGRect, - fallbackColor: UIColor, - font: UIFont - ) { - let attributedText = tokenAttributedString( - rowId: rowId, - tokens: tokens, - fallbackColor: fallbackColor, - font: font - ) - attributedText.draw(in: rect) - } - private func tokenAttributedString( rowId: String, tokens: [ReviewDiffNativeToken], diff --git a/apps/mobile/modules/t3-review-diff/tests/ios/main.swift b/apps/mobile/modules/t3-review-diff/tests/ios/main.swift new file mode 100644 index 000000000000..baeeaae77d9a --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/ios/main.swift @@ -0,0 +1,60 @@ +import UIKit + +func check(_ passed: Bool, _ message: String = "Failed layout check") { + if !passed { + FileHandle.standardError.write(Data((message + "\n").utf8)) + exit(1) + } +} + +// Runs the production layout against UIKit through Mac Catalyst, without launching an app. +let font = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) +let characterWidth = ("M" as NSString).size(withAttributes: [.font: font]).width +let fixtures = ["漢字表示", "e\u{301}", "👨‍👩‍👧‍👦", "مرحبا بالعالم ", "\tvalue "] +var cases = 0 +for fixture in fixtures { + let text = String(repeating: fixture, count: 40) + var previousHeight = CGFloat.greatestFiniteMagnitude + for width: CGFloat in [180, 280, 420] { + let layout = ReviewDiffCodeLayout(text: text, font: font, width: width, characterWidth: characterWidth) + let height = layout.firstLineHeight + layout.extraHeight + check(height <= previousHeight, "Wider text must not require more height") + previousHeight = height + let fullRange = NSRange(location: 0, length: text.utf16.count) + let attributed = NSAttributedString(string: text, attributes: [.foregroundColor: UIColor.black]) + layout.decorate(text: attributed, highlights: [], color: .clear, version: 0) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + format.opaque = false + format.preferredRange = .standard + let size = CGSize(width: width + 40, height: ceil(height)) + let image = UIGraphicsImageRenderer(size: size, format: format).image { _ in + layout.draw(at: .zero, clip: CGRect(origin: .zero, size: size)) + } + let bitmap = image.cgImage! + let data = bitmap.dataProvider!.data! + let bytes = CFDataGetBytePtr(data)! + // Render without a viewport clip so an overflowing glyph cannot hide behind clipping. + for y in 0.. + ); + } + return ; } diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 82b2b90fecad..8d2a67000050 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -1,3 +1,8 @@ +import { + deviceToolVersionLabels, + deviceToolUpdateOwnership, + deviceToolUpdatePolicy, +} from "@t3tools/client-runtime/state/device"; import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; @@ -68,11 +73,12 @@ function DevicePreviewScreen({ const insets = useSafeAreaInsets(); const { themeVariables } = useAppearancePreferences(); const focused = useIsFocused(); - const [foreground, setForeground] = useState(AppState.currentState === "active"); + const [foreground, setForeground] = useState(AppState.currentState !== "background"); const [selectedKey, setSelectedKey] = useState(null); const [inputConnected, setInputConnected] = useState(false); const [streamAttempt, setStreamAttempt] = useState(0); const [shuttingDown, setShuttingDown] = useState(false); + const retryHost = useAtomCommand(deviceEnvironment.list); const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false }); const streamRef = useRef(null); const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} })); @@ -87,7 +93,7 @@ function DevicePreviewScreen({ ); useEffect(() => { const subscription = AppState.addEventListener("change", (state) => - setForeground(state === "active"), + setForeground(state !== "background"), ); return () => subscription.remove(); }, []); @@ -116,10 +122,58 @@ function DevicePreviewScreen({ }; const controls: ScreenHeaderMenuItem[] = [ + ...(state.data?.hosts + .filter( + (host) => + state.data?.supportsHostRetry && state.data.hostStatuses[host.id]?.status === "failed", + ) + .map((host) => ({ + id: `retry-${host.id}`, + title: `Retry ${host.label}`, + icon: "arrow.clockwise" as const, + onPress: () => { + void retryHost({ environmentId, input: { retryHostId: host.id } }); + }, + })) ?? []), + ...(state.data?.supportsToolInspection + ? [ + { + id: "check-device-tools", + title: "Check device tool versions", + icon: "arrow.clockwise" as const, + onPress: () => { + void retryHost({ environmentId, input: { inspectOnly: true } }); + }, + }, + ] + : []), + { + id: "device-tools", + title: "Device tool versions", + icon: "info.circle", + onPress: () => + Alert.alert( + "Device tool versions", + deviceToolUpdateOwnership + + "\n\n" + + deviceToolUpdatePolicy( + state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, + ) + + "\n\n" + + deviceToolVersionLabels( + state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, + ).join("\n") + + "\n" + + (state.data?.hosts.find((host) => host.id === preview?.session.hostId) + ?.toolInspectionError ?? + state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ?? + ""), + ), + }, { id: "reload", title: "Reload stream", - icon: "arrow.clockwise", + icon: "arrow.clockwise" as const, disabled: !preview || shuttingDown, onPress: () => { setInputConnected(false); @@ -149,7 +203,7 @@ function DevicePreviewScreen({ { id: "rotate", title: "Rotate device", - icon: "arrow.clockwise", + icon: "arrow.clockwise" as const, disabled: !inputConnected, onPress: () => streamRef.current?.rotate(), }, diff --git a/apps/mobile/src/features/devices/DeviceStreamWebView.tsx b/apps/mobile/src/features/devices/DeviceStreamWebView.tsx index 9a5cef83ae3f..423fdb1c84a0 100644 --- a/apps/mobile/src/features/devices/DeviceStreamWebView.tsx +++ b/apps/mobile/src/features/devices/DeviceStreamWebView.tsx @@ -1,7 +1,19 @@ import deviceStreamScript from "@t3tools/mobile-device-stream"; -import { useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type Ref } from "react"; -import { Platform } from "react-native"; +import { + useEffect, + useEffectEvent, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, + type Ref, +} from "react"; +import { ActivityIndicator, Platform, Pressable, View } from "react-native"; import { WebView } from "react-native-webview"; +import type { DeviceStreamStatus } from "@t3tools/client-runtime/device/stream"; + +import { AppText } from "../../components/AppText"; import { deviceStreamDocument, @@ -27,6 +39,7 @@ export function DeviceStreamWebView({ ...props }: DeviceStreamConfiguration & NativeStreamBridge) { const [attempt, setAttempt] = useState(0); + const processRetried = useRef(false); const configuration = JSON.stringify({ access: props.access, platform: props.platform, @@ -41,7 +54,21 @@ export function DeviceStreamWebView({ background={props.colors.background} onUnauthorized={props.onUnauthorized} onInputConnected={props.onInputConnected} - onRetry={() => setAttempt((attempt) => attempt + 1)} + onRetry={() => { + processRetried.current = false; + setAttempt((attempt) => attempt + 1); + void props.onUnauthorized(); + }} + onStreaming={() => { + processRetried.current = false; + }} + onRecoverProcess={() => { + if (processRetried.current) return false; + processRetried.current = true; + setAttempt((attempt) => attempt + 1); + void props.onUnauthorized(); + return true; + }} /> ); } @@ -53,12 +80,38 @@ function DeviceStreamDocumentView({ onUnauthorized, onInputConnected, onRetry, + onStreaming, + onRecoverProcess, }: NativeStreamBridge & { readonly configuration: string; readonly background: string; readonly onRetry: () => void; + readonly onStreaming: () => void; + readonly onRecoverProcess: () => boolean; }) { const webView = useRef(null); + const active = useRef(true); + const failed = useRef(false); + const [status, setStatus] = useState("connecting"); + const [error, setError] = useState(null); + const [started, setStarted] = useState(false); + const fail = (message: string) => { + if (!active.current || failed.current) return; + failed.current = true; + void onInputConnected(false); + webView.current?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); + setError(message); + setStatus("error"); + }; + // The shared transport owns video timeouts once the document acknowledges startup. + const bootstrapTimedOut = useEffectEvent(() => + fail("Device viewer could not start. Reconnect to try again."), + ); + useEffect(() => { + if (started) return; + const timer = setTimeout(bootstrapTimedOut, 15_000); + return () => clearTimeout(timer); + }, [started]); const source = useMemo( () => ({ html: deviceStreamDocument(configuration, deviceStreamScript), @@ -78,32 +131,80 @@ function DeviceStreamDocumentView({ appSwitcher: () => command("appSwitcher"), rotate: () => command("rotate"), })); + const resetInput = useEffectEvent(() => void onInputConnected(false)); useLayoutEffect(() => { + active.current = true; + resetInput(); const view = webView.current; - return () => view?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); + return () => { + active.current = false; + view?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); + }; }, []); + const processTerminated = () => { + if (!active.current || failed.current) return; + void onInputConnected(false); + if (!onRecoverProcess()) fail("Device viewer stopped. Reconnect to try again."); + }; return ( - void onInputConnected(false)} - onShouldStartLoadWithRequest={(request) => - request.url === "about:blank" || request.url === source.baseUrl - } - onMessage={(event) => { - const message = deviceStreamMessage(event.nativeEvent.data); - if (message?.type === "unauthorized") void onUnauthorized(); - else if (message?.type === "input") void onInputConnected(message.connected); - else if (message?.type === "retry") onRetry(); - }} - /> + + fail("Device viewer could not load. Reconnect to try again.")} + onHttpError={() => fail("Device viewer could not load. Reconnect to try again.")} + onContentProcessDidTerminate={processTerminated} + onRenderProcessGone={processTerminated} + onShouldStartLoadWithRequest={(request) => + request.url === "about:blank" || request.url === source.baseUrl + } + onMessage={(event) => { + if (!active.current || failed.current) return; + const message = deviceStreamMessage(event.nativeEvent.data); + if (message?.type === "unauthorized") void onUnauthorized(); + else if (message?.type === "input") void onInputConnected(message.connected); + else if (message?.type === "retry") onRetry(); + else if (message?.type === "status") { + setStarted(true); + if (message.status === "error") fail(message.detail ?? "Device stream failed."); + else { + setStatus(message.status); + if (message.status === "streaming") onStreaming(); + } + } + }} + /> + {status !== "streaming" ? ( + + {status === "connecting" ? : null} + + {status === "error" ? error : "Connecting to device..."} + + {status === "error" ? ( + + Reconnect + + ) : null} + + ) : null} + ); } diff --git a/apps/mobile/src/features/devices/device-stream-document.test.ts b/apps/mobile/src/features/devices/device-stream-document.test.ts index ffc08667a147..95aa6a9dad23 100644 --- a/apps/mobile/src/features/devices/device-stream-document.test.ts +++ b/apps/mobile/src/features/devices/device-stream-document.test.ts @@ -1,5 +1,5 @@ import * as NodeVM from "node:vm"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { deviceStreamDocument, deviceStreamMessage } from "./device-stream-document"; @@ -13,7 +13,7 @@ describe("native device stream document", () => { ); const script = html.match(/`; + const failure = `window.ReactNativeWebView.postMessage(JSON.stringify({type:"status",status:"error",detail:"Device viewer stopped unexpectedly."}));`; + return ``; } export function deviceStreamMessage(data: string) { @@ -36,6 +37,21 @@ export function deviceStreamMessage(data: string) { ) { return { type: message.type, connected: message.connected } as const; } + if ( + message.type === "status" && + "status" in message && + (message.status === "connecting" || + message.status === "streaming" || + message.status === "error") && + (!("detail" in message) || typeof message.detail === "string") + ) { + return { + type: message.type, + status: message.status, + detail: + "detail" in message && typeof message.detail === "string" ? message.detail : undefined, + } as const; + } } catch { // Ignore messages that are not part of the stream bridge. } diff --git a/apps/mobile/src/features/devices/device-stream.browser.test.ts b/apps/mobile/src/features/devices/device-stream.browser.test.ts new file mode 100644 index 000000000000..5aeefdac0203 --- /dev/null +++ b/apps/mobile/src/features/devices/device-stream.browser.test.ts @@ -0,0 +1,117 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; +import { start, stop } from "./device-stream.browser"; + +class Element extends EventTarget { + readonly style = {}; + naturalWidth = 0; + naturalHeight = 0; + src = ""; + readonly tag: string; + constructor(tag: string) { + super(); + this.tag = tag; + } + setAttribute() {} + removeAttribute(name: string) { + if (name === "src") this.src = ""; + } + append() {} +} + +async function setup() { + vi.useFakeTimers(); + const elements: Element[] = []; + vi.stubGlobal("document", { + documentElement: { style: {} }, + body: { style: {}, replaceChildren() {} }, + createElement: (tag: string) => { + const element = new Element(tag); + elements.push(element); + return element; + }, + }); + const postMessage = vi.fn(); + vi.stubGlobal("window", { ReactNativeWebView: { postMessage }, addEventListener() {} }); + vi.stubGlobal("fetch", () => Promise.resolve(new Response("prime"))); + const sockets: Socket[] = []; + class Socket { + static OPEN = 1; + readyState = 1; + onopen: (() => void) | null = null; + close = vi.fn(); + send = vi.fn(); + constructor() { + sockets.push(this); + } + } + vi.stubGlobal("WebSocket", Socket); + const configuration = { + platform: "ios" as const, + deviceId: "fixture-device", + access: { + httpBase: "https://device.test", + wsBase: "wss://device.test", + credentials: false, + query: {}, + }, + colors: { + background: "white", + foreground: "black", + muted: "gray", + buttonBackground: "gray", + buttonForeground: "black", + buttonBorder: "gray", + }, + }; + start(configuration); + await vi.advanceTimersByTimeAsync(0); + return { + configuration, + elements, + sockets, + messages: () => + postMessage.mock.calls.map( + ([message]) => JSON.parse(message as string) as { type: string; status?: string }, + ), + }; +} + +afterEach(() => { + stop(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it("bridges shared first-frame readiness, image failure, and a successful fresh attempt to native", async () => { + const { elements, messages, sockets, configuration } = await setup(); + sockets[0]!.onopen?.(); + expect(messages()).not.toContainEqual({ type: "status", status: "streaming" }); + expect(messages()).toContainEqual({ type: "input", connected: true }); + const image = elements.find((element) => element.tag === "img")!; + image.naturalWidth = 400; + image.naturalHeight = 800; + await vi.advanceTimersByTimeAsync(250); + expect(messages()).toContainEqual({ type: "status", status: "streaming" }); + image.dispatchEvent(new Event("error")); + expect(messages()).toContainEqual({ + type: "status", + status: "error", + detail: "Could not receive the device stream. Reconnect to try again.", + }); + expect(messages()).toContainEqual({ type: "input", connected: false }); + expect(messages()).not.toContainEqual({ type: "unauthorized" }); + expect(image.src).toBe(""); + expect(sockets[0]!.close).toHaveBeenCalledOnce(); + start(configuration); + await vi.advanceTimersByTimeAsync(0); + const replacement = elements.findLast((element) => element.tag === "img")!; + replacement.naturalWidth = 400; + replacement.naturalHeight = 800; + replacement.dispatchEvent(new Event("load")); + expect(messages().at(-1)).toEqual({ type: "status", status: "streaming" }); + image.dispatchEvent(new Event("error")); + expect(sockets[1]!.close).not.toHaveBeenCalled(); + stop(); + expect(replacement.src).toBe(""); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/apps/mobile/src/features/devices/device-stream.browser.ts b/apps/mobile/src/features/devices/device-stream.browser.ts index efa81d9a043a..0b9e98a51b9f 100644 --- a/apps/mobile/src/features/devices/device-stream.browser.ts +++ b/apps/mobile/src/features/devices/device-stream.browser.ts @@ -12,13 +12,10 @@ declare global { } let activeClient: ReturnType | null = null; -let activeImage: HTMLImageElement | null = null; export function stop() { activeClient?.stop(); activeClient = null; - activeImage?.removeAttribute("src"); - activeImage = null; } export function command(button: "home" | "back" | "appSwitcher" | "rotate") { @@ -70,34 +67,6 @@ export function start(configuration: DeviceStreamConfiguration) { image.alt = ""; image.draggable = false; image.style.display = "none"; - const overlay = document.createElement("div"); - overlay.setAttribute("role", "status"); - Object.assign(overlay.style, { - position: "fixed", - inset: "0", - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - gap: "16px", - padding: "24px", - textAlign: "center", - background: colors.background, - }); - const detail = document.createElement("span"); - const retry = document.createElement("button"); - retry.textContent = "Retry"; - Object.assign(retry.style, { - padding: "12px 24px", - borderRadius: "20px", - border: `1px solid ${colors.buttonBorder}`, - background: colors.buttonBackground, - color: colors.buttonForeground, - font: "inherit", - display: "none", - }); - retry.addEventListener("click", () => post({ type: "retry" })); - overlay.append(detail, retry); const inputStatus = document.createElement("div"); inputStatus.setAttribute("role", "status"); inputStatus.textContent = "Reconnecting device controls..."; @@ -114,11 +83,17 @@ export function start(configuration: DeviceStreamConfiguration) { }); frame.append(canvas, image); container.append(frame); - document.body.replaceChildren(container, overlay, inputStatus); + document.body.replaceChildren(container, inputStatus); let pointerId: number | null = null; let inputConnected = false; let streaming = false; + const reportStatus = (status: "connecting" | "streaming" | "error", detail?: string) => { + if (activeClient !== client) return; + streaming = status === "streaming"; + inputStatus.style.display = streaming && !inputConnected ? "block" : "none"; + post({ type: "status", status, detail }); + }; const layout = (screen: DeviceScreenSize | null) => { const landscape = screen?.orientation === "landscape_left" || screen?.orientation === "landscape_right"; @@ -160,19 +135,11 @@ export function start(configuration: DeviceStreamConfiguration) { { ...configuration, preferMjpeg: platform === "ios" }, canvas, { - onStatus: (status, message) => { - streaming = status === "streaming"; - overlay.style.display = streaming ? "none" : "flex"; - inputStatus.style.display = streaming && !inputConnected ? "block" : "none"; - detail.textContent = - status === "error" ? (message ?? "Device stream failed.") : "Connecting to device..."; - retry.style.display = status === "error" ? "block" : "none"; - }, + onStatus: reportStatus, onScreen: layout, - onMjpegFallback: (url) => { + onMjpegFallback: () => { canvas.style.display = "none"; image.style.display = "block"; - image.src = url; }, onUnauthorized: unauthorized, onInputConnected: (connected) => { @@ -183,8 +150,7 @@ export function start(configuration: DeviceStreamConfiguration) { }, ); activeClient = client; - activeImage = image; - image.addEventListener("error", unauthorized); + client.setMjpegImage(image); const touch = (event: PointerEvent, phase: "begin" | "move" | "end") => { const rect = frame.getBoundingClientRect(); client.sendTouch( diff --git a/apps/mobile/src/features/review/ReviewCommentCard.tsx b/apps/mobile/src/features/review/ReviewCommentCard.tsx index ff348e1f2a97..5be4e87cba40 100644 --- a/apps/mobile/src/features/review/ReviewCommentCard.tsx +++ b/apps/mobile/src/features/review/ReviewCommentCard.tsx @@ -110,8 +110,9 @@ export const ReviewCommentCard = memo(function ReviewCommentCard(props: { () => JSON.stringify(nativeReviewDiffTheme), [nativeReviewDiffTheme], ); + // The card's height is sized from its row count, so its snippet stays unwrapped. const nativeStyleJson = useMemo( - () => JSON.stringify(nativeReviewDiffStyle), + () => JSON.stringify({ ...nativeReviewDiffStyle, wordWrap: false }), [nativeReviewDiffStyle], ); const nativeDiffHeight = useMemo( diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index 8bea04c524dd..3056cc2136ca 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -63,8 +63,13 @@ function opaqueNativeHexColor(color: string, background: string): string { return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; } -export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) { +/** `wordWrap` wraps line rows at the view width instead of panning them horizontally. */ +export function createNativeReviewDiffStyle( + codeSurface: ResolvedMobileCodeSurface, + wordWrap: boolean, +) { return { + wordWrap, rowHeight: codeSurface.rowHeight, contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, changeBarWidth: 4, diff --git a/apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts new file mode 100644 index 000000000000..3792e49f54c7 --- /dev/null +++ b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createReviewDiffHighlightScheduler } from "./reviewDiffHighlightScheduler"; + +describe("review diff highlighting while scrolling", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("keeps requesting new rows during gradual scrolling through a large diff", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + for (let firstRowIndex = 1; firstRowIndex <= 1_674; firstRowIndex++) { + scheduler.update({ firstRowIndex, lastRowIndex: firstRowIndex + 80 }); + vi.advanceTimersByTime(16); + } + expect(request.mock.calls.length).toBeGreaterThan(100); + vi.advanceTimersByTime(150); + expect(request).toHaveBeenLastCalledWith({ firstRowIndex: 1_674, lastRowIndex: 1_754 }); + }); + + it("highlights the settled viewport even below the movement threshold", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + scheduler.update({ firstRowIndex: 2, lastRowIndex: 82 }); + vi.advanceTimersByTime(100); + scheduler.update({ firstRowIndex: 3, lastRowIndex: 83 }); + vi.advanceTimersByTime(100); + expect(request).not.toHaveBeenCalled(); + vi.advanceTimersByTime(50); + expect(request).toHaveBeenCalledExactlyOnceWith({ firstRowIndex: 3, lastRowIndex: 83 }); + }); + + it("does not let repeated draw events starve the settled refresh", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + for (let i = 0; i < 10; i++) { + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + vi.advanceTimersByTime(30); + } + expect(request).toHaveBeenCalledExactlyOnceWith({ firstRowIndex: 1, lastRowIndex: 81 }); + }); + + it("requests large jumps and reverse scrolling immediately without stale timers", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + scheduler.update({ firstRowIndex: 1_000, lastRowIndex: 1_080 }); + scheduler.update({ firstRowIndex: 0, lastRowIndex: 80 }); + vi.runAllTimers(); + expect(request.mock.calls).toEqual([ + [{ firstRowIndex: 1_000, lastRowIndex: 1_080 }], + [{ firstRowIndex: 0, lastRowIndex: 80 }], + ]); + }); + + it("cancels pending work on disposal and resets the range for a new diff", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + scheduler.cancel(); + vi.runAllTimers(); + expect(request).not.toHaveBeenCalled(); + scheduler.update({ firstRowIndex: 1_000, lastRowIndex: 1_080 }); + request.mockClear(); + scheduler.update({ firstRowIndex: 1_001, lastRowIndex: 1_081 }); + scheduler.reset(); + vi.runAllTimers(); + expect(request).not.toHaveBeenCalled(); + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + expect(request).not.toHaveBeenCalled(); + vi.advanceTimersByTime(150); + expect(request).toHaveBeenCalledExactlyOnceWith({ firstRowIndex: 1, lastRowIndex: 81 }); + }); +}); diff --git a/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts new file mode 100644 index 000000000000..4cededa52954 --- /dev/null +++ b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts @@ -0,0 +1,51 @@ +export interface NativeReviewVisibleRange { + readonly firstRowIndex: number; + readonly lastRowIndex: number; +} + +export function createReviewDiffHighlightScheduler( + request: (range: NativeReviewVisibleRange) => void, +) { + let requestedRange: NativeReviewVisibleRange = { firstRowIndex: 0, lastRowIndex: 80 }; + let visibleRange = requestedRange; + let timer: ReturnType | undefined; + + const cancel = () => { + clearTimeout(timer); + timer = undefined; + }; + const flush = () => { + cancel(); + requestedRange = visibleRange; + request(visibleRange); + }; + + return { + update(nextRange: NativeReviewVisibleRange) { + if ( + nextRange.firstRowIndex === visibleRange.firstRowIndex && + nextRange.lastRowIndex === visibleRange.lastRowIndex + ) { + return; + } + visibleRange = nextRange; + cancel(); + // Accumulate small scroll events relative to the last request, not each other. + const movedRows = + Math.abs(nextRange.firstRowIndex - requestedRange.firstRowIndex) + + Math.abs(nextRange.lastRowIndex - requestedRange.lastRowIndex); + if (movedRows >= 20) { + flush(); + } else if (movedRows > 0) { + // Cover the final viewport even when scrolling stops below the threshold. + timer = setTimeout(flush, 150); + } + }, + reset() { + cancel(); + requestedRange = { firstRowIndex: 0, lastRowIndex: 80 }; + visibleRange = requestedRange; + }, + cancel, + }; +} diff --git a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts index 35f06c263666..61a205f9d917 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { highlightNativeReviewDiffVisibleRows, @@ -8,10 +8,10 @@ import { import type { NativeReviewDiffRow } from "../diffs/nativeReviewDiffSurface"; import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes"; -interface NativeReviewVisibleRange { - readonly firstRowIndex: number; - readonly lastRowIndex: number; -} +import { + createReviewDiffHighlightScheduler, + type NativeReviewVisibleRange, +} from "./reviewDiffHighlightScheduler"; function createEmptyTokenPatch(resetKey: string): string { return JSON.stringify({ resetKey, tokensByRowId: {} }); @@ -43,23 +43,22 @@ export function useNativeReviewDiffHighlighting(input: { }) { const { enabled, files, resetKey, rows, scheme } = input; const highlightedRowIdsRef = useRef>(new Set()); - const visibleRangeRef = useRef({ + const [visibleRange, setVisibleRange] = useState({ firstRowIndex: 0, lastRowIndex: 80, }); const visibleChunkIndexRef = useRef(0); const [tokensPatchJson, setTokensPatchJson] = useState(() => createEmptyTokenPatch(resetKey)); - const [visibleHighlightRequest, setVisibleHighlightRequest] = useState(0); + const [scheduler] = useState(() => createReviewDiffHighlightScheduler(setVisibleRange)); useEffect(() => { + scheduler.reset(); highlightedRowIdsRef.current = new Set(); visibleChunkIndexRef.current = 0; - visibleRangeRef.current = { firstRowIndex: 0, lastRowIndex: 80 }; + setVisibleRange({ firstRowIndex: 0, lastRowIndex: 80 }); setTokensPatchJson(createEmptyTokenPatch(resetKey)); - if (enabled && rows.length > 0) { - setVisibleHighlightRequest((request) => request + 1); - } - }, [enabled, resetKey, rows.length]); + return () => scheduler.cancel(); + }, [enabled, resetKey, rows.length, scheduler]); useEffect(() => { if (!enabled || rows.length === 0) { @@ -67,7 +66,7 @@ export function useNativeReviewDiffHighlighting(input: { } const abortController = new AbortController(); - const requestRange = visibleRangeRef.current; + const requestRange = visibleRange; const engine: NativeReviewDiffHighlightEngine = "native"; void (async () => { @@ -119,22 +118,10 @@ export function useNativeReviewDiffHighlighting(input: { })(); return () => abortController.abort(); - }, [enabled, files, resetKey, rows, scheme, visibleHighlightRequest]); - - const updateVisibleRange = useCallback((nextRange: NativeReviewVisibleRange) => { - const previousRange = visibleRangeRef.current; - const movedRows = - Math.abs(nextRange.firstRowIndex - previousRange.firstRowIndex) + - Math.abs(nextRange.lastRowIndex - previousRange.lastRowIndex); - - visibleRangeRef.current = nextRange; - if (movedRows >= 20) { - setVisibleHighlightRequest((request) => request + 1); - } - }, []); + }, [enabled, files, resetKey, rows, scheme, visibleRange]); return { tokensPatchJson, - updateVisibleRange, + updateVisibleRange: scheduler.update, }; } diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 090467a8cd90..8aff022910d0 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -6,6 +6,7 @@ import { type ServerSettings, type ServerSettingsPatch, type ThreadEnvMode, + type WorktreeSubmodules, PROJECT_SCOPED_SERVER_SETTING_KEYS, type ProjectScopedServerSettingKey, } from "@t3tools/contracts"; @@ -43,17 +44,43 @@ const PAGE_TITLES: Record = { }; const PAGE_PROJECT_KEYS: Record = { - "new-threads": ["defaultThreadEnvMode", "defaultRuntimeMode"], + "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"], "source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin"], "agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"], maintenance: ["continueThreadsAfterServerUpdate"], }; +const SUBMODULE_CHOICES: ReadonlyArray<{ + readonly mode: WorktreeSubmodules | null; + readonly label: string; + readonly description: string; +}> = [ + // Only offered at environment scope; a project falls back through "Use defaults". + { + mode: null, + label: "Inherit", + description: "Use the repository's t3.json, or initialize recursively.", + }, + { mode: "recursive", label: "Recursive", description: "Initialize nested submodules too." }, + { + mode: "top-level", + label: "Top level only", + description: "Skip submodules declared inside other submodules.", + }, + { mode: "none", label: "Skip", description: "Leave submodules empty for a setup script." }, +]; + const WORKSPACE_CHOICES: ReadonlyArray<{ - readonly mode: ThreadEnvMode; + readonly mode: ThreadEnvMode | null; readonly label: string; readonly description: string; }> = [ + // Only offered at environment scope; a project falls back through "Use defaults". + { + mode: null, + label: "Inherit", + description: "Use the repository's t3.json, or the current checkout.", + }, { mode: "local", label: "Current checkout", @@ -126,6 +153,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { const value = reference.settings[key]; return displayTargets.every((entry) => entry.settings[key] === value) ? value : null; }; + // `uniform` folds a real null into "mixed"; nullable keys need the distinction. + const isMixed = (key: keyof ServerSettings) => + reference === null || + displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "environment settings update", reportFailure: true, @@ -219,23 +250,53 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ) : null } > - {WORKSPACE_CHOICES.map((choice, index) => ( + {WORKSPACE_CHOICES.filter( + (choice) => choice.mode !== null || !projectSelected, + ).map((choice, index) => ( 0} disabled={disabledFor("defaultThreadEnvMode")} onPress={() => write({ defaultThreadEnvMode: choice.mode })} /> ))} + + ) : null + } + > + {SUBMODULE_CHOICES.filter( + (choice) => choice.mode !== null || !projectSelected, + ).map((choice, index) => ( + 0} + disabled={disabledFor("worktreeSubmodules")} + onPress={() => write({ worktreeSubmodules: choice.mode })} + /> + ))} + createNativeSourceStyle(codeSurface), [codeSurface]); const nativeReviewDiffStyle = useMemo( - () => createNativeReviewDiffStyle(codeSurface), - [codeSurface], + () => createNativeReviewDiffStyle(codeSurface, appearance.codeWordBreak), + [appearance.codeWordBreak, codeSurface], ); return { diff --git a/apps/mobile/src/features/settings/settings-scoped-server.test.ts b/apps/mobile/src/features/settings/settings-scoped-server.test.ts index dd589cd2038c..185fb07e602f 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.test.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.test.ts @@ -69,6 +69,39 @@ describe("mobile project settings scope", () => { expect(secondSettings.responseStreamingMode).toBe("token"); }); + it("removes a project override when a picker sends null for a key that cannot store it", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [firstProject]: { defaultThreadEnvMode: "worktree", defaultAutoPull: true }, + }, + }; + const targets = resolveMobileSettingsTargets( + [environment(firstId, settings)], + [{ environmentId: firstId, id: firstProject }], + ); + expect(planMobileScopedSettingsPatch(targets, true, { defaultThreadEnvMode: null })).toEqual([ + { + environmentId: firstId, + patch: { projectSettingsOverrides: { [firstProject]: { defaultAutoPull: true } } }, + }, + ]); + expect(planMobileScopedSettingsPatch(targets, true, { defaultModelSelection: null })).toEqual([ + { + environmentId: firstId, + patch: { + projectSettingsOverrides: { + [firstProject]: { + defaultThreadEnvMode: "worktree", + defaultAutoPull: true, + defaultModelSelection: null, + }, + }, + }, + }, + ]); + }); + it("resets only the selected page's override and rejects environment-wide writes", () => { const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, diff --git a/apps/mobile/src/features/settings/settings-scoped-server.ts b/apps/mobile/src/features/settings/settings-scoped-server.ts index 53cc44a7ee1c..01020a3eb221 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.ts @@ -1,4 +1,5 @@ import { + isNullableProjectSettingsOverride, PROJECT_SCOPED_SERVER_SETTING_KEYS, type EnvironmentId, type ProjectId, @@ -69,7 +70,19 @@ export function planMobileScopedSettingsPatch( continue; const current = target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {}; - const next = { ...current, ...patch }; + const next: Record = { ...current }; + for (const [key, value] of Object.entries(patch)) { + // A picker's "Inherit" sends null; for keys whose override cannot + // store null that means remove the override. + if ( + value === null && + !isNullableProjectSettingsOverride(key as ProjectScopedServerSettingKey) + ) { + delete next[key]; + } else { + next[key] = value; + } + } const overrides = writes.get(target.environment.environmentId) ?? {}; overrides[target.projectId] = next; writes.set(target.environment.environmentId, overrides); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 9732ed6865f5..681f6c5ee52d 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1455,7 +1455,7 @@ export function NewTaskDraftScreen(props: { in { - if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; - return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; - }, [t3ProjectFileData]); - // Environment settings with the project's overrides applied; the - // aggregate's own legacy fields still count until the server folds them. + const t3ProjectFile = useMemo( + () => + t3ProjectFileData === null || t3ProjectFileData.truncated + ? null + : parseT3ProjectFile(t3ProjectFileData.contents), + [t3ProjectFileData], + ); + // Environment settings with the project's overrides and its t3.json + // applied; the aggregate's own legacy fields still count until the server + // folds them. const projectSettings = useMemo( () => resolveProjectSettings( selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS, selectedProject?.id ?? null, selectedProject, + t3ProjectFile, ), - [selectedEnvironmentServerConfig?.settings, selectedProject], + [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile], ); - const projectThreadEnvMode = - projectSettings.sources.defaultThreadEnvMode === "project" - ? projectSettings.settings.defaultThreadEnvMode - : undefined; - const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ - projectSetting: projectThreadEnvMode, - projectFile: t3ProjectFileDefaultMode, - globalDefault: projectSettings.settings.defaultThreadEnvMode, - }); - // While unsettled the resolved default is provisional. Nothing may write - // it into the draft during that window (the auto-branch effect does), or - // the frozen interim value beats the t3.json default once it loads. - const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ - explicitMode: selectedProjectDraft.workspaceSelection?.mode, - projectSetting: projectThreadEnvMode, - projectFilePending: t3ProjectFileQuery.isPending, - }); + const defaultWorkspaceMode: WorkspaceMode = projectSettings.settings.defaultThreadEnvMode; + // While the file read is pending and nothing above it decided, the + // resolved default is provisional. Nothing may write it into the draft + // during that window (the auto-branch effect does), or the frozen interim + // value beats the t3.json default once it loads. + const defaultWorkspaceModeSettled = + selectedProjectDraft.workspaceSelection?.mode !== undefined || + projectSettings.sources.defaultThreadEnvMode !== "environment" || + !t3ProjectFileQuery.isPending; const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; @@ -873,12 +866,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { useEffect(() => { if ( + !selectedProjectDraftKey || !defaultWorkspaceModeSettled || workspaceMode !== "worktree" || selectedBranchName !== null ) { return; } + // The draft screen writes a thread's branch and worktree into the draft in + // the same commit this effect runs, so the rendered selection above can be + // stale. Re-read the draft before replacing it. + const live = getComposerDraftSnapshot(selectedProjectDraftKey).workspaceSelection; + if (live && (live.mode !== "worktree" || live.branch !== null)) { + return; + } // The default may only exist as origin/ (isRemote), which // availableBranches filters out — search the unfiltered refs for it. const preferredBranch = @@ -894,6 +895,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { defaultWorkspaceModeSettled, selectBranch, selectedBranchName, + selectedProjectDraftKey, workspaceMode, ]); diff --git a/apps/mobile/src/lib/composerFiles.test.ts b/apps/mobile/src/lib/composerFiles.test.ts index 962be35013f2..a99c2c6064e7 100644 --- a/apps/mobile/src/lib/composerFiles.test.ts +++ b/apps/mobile/src/lib/composerFiles.test.ts @@ -477,10 +477,13 @@ describe("composer file attachments", () => { it("applies the remaining attachment slots to photos and videos together", async () => { mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] }); - const result = await pickComposerMedia({ existingCount: 7, maxVideoBytes: 50 * 1024 * 1024 }); + const result = await pickComposerMedia({ + existingCount: 99, + maxVideoBytes: 50 * 1024 * 1024, + }); expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]); - expect(result.error).toBe("You can attach up to 8 attachments per message."); + expect(result.error).toBe("You can attach up to 100 attachments per message."); expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ selectionLimit: 1 })); expect(mocks.copy).not.toHaveBeenCalled(); }); @@ -625,9 +628,9 @@ describe("composer file attachments", () => { }); it("does not open the picker when the draft has no remaining attachment slots", async () => { - await expect(pickComposerFiles({ existingCount: 8 })).resolves.toEqual({ + await expect(pickComposerFiles({ existingCount: 100 })).resolves.toEqual({ files: [], - error: "You can attach up to 8 files per message.", + error: "You can attach up to 100 files per message.", }); expect(mocks.pickFile).not.toHaveBeenCalled(); @@ -838,7 +841,7 @@ describe("composer file attachments", () => { ], }); - const result = await pickComposerFiles({ existingCount: 7, maxBytes: 1024 * 1024 }); + const result = await pickComposerFiles({ existingCount: 99, maxBytes: 1024 * 1024 }); expect(result.files.map((file) => file.name)).toEqual(["report.pdf"]); }); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 6da72860009e..9f5dad4d5600 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -676,7 +676,7 @@ describe("mobile composer drafts", () => { return undefined; }); const key = "environment-1:replace-file"; - const files = Array.from({ length: 8 }, (_, index) => ({ + const files = Array.from({ length: 100 }, (_, index) => ({ id: `file-${index}`, type: "file" as const, name: `notes-${index}.txt`, @@ -687,8 +687,8 @@ describe("mobile composer drafts", () => { appendComposerDraftAttachments(key, files, { appendReference: true }); const firstLink = "[notes-0.txt](t3-context://v1/file/file-0)"; const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length }); - expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(7); - expect(getComposerDraftAfterSelection(key, insertion).context?.records).toHaveLength(7); + expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(99); + expect(getComposerDraftAfterSelection(key, insertion).context?.records).toHaveLength(99); const replacement = { ...files[0]!, id: "replacement", fileUri: "file:///replacement.txt" }; if (kind === "attachment") { expect( @@ -716,7 +716,7 @@ describe("mobile composer drafts", () => { insertion, ), ).toBe(true); - expect(getComposerDraftSnapshot(key).attachments).toHaveLength(8); + expect(getComposerDraftSnapshot(key).attachments).toHaveLength(100); expect(getComposerDraftSnapshot(key).context?.records).toContainEqual(record); expect(getComposerDraftSnapshot(key).text).toBe( `${formatComposerContextReference(record)}${insertion.text.slice(firstLink.length)}`, @@ -730,7 +730,7 @@ describe("mobile composer drafts", () => { } const draft = getComposerDraftSnapshot(key); expect(draft.attachments.map((file) => file.id)).not.toContain("file-0"); - expect(draft.attachments.slice(0, 7)).toEqual(files.slice(1)); + expect(draft.attachments.slice(0, 99)).toEqual(files.slice(1)); expect(draft.context?.records.some((record) => record.contextId === "file-0")).toBe(false); await cleanup.promise; expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(files[0]!.fileUri); @@ -746,7 +746,7 @@ describe("mobile composer drafts", () => { return undefined; }); const key = "environment-1:concurrent-import"; - const files = Array.from({ length: 8 }, (_, index) => ({ + const files = Array.from({ length: 100 }, (_, index) => ({ id: `existing-${index}`, type: "file" as const, name: `notes-${index}.txt`, @@ -906,7 +906,7 @@ describe("mobile composer drafts", () => { fileUri: `file:///documents/t3-composer-attachments/${id}.mov`, }); const draftKey = "new-task:environment-1:project-cap"; - const existing = Array.from({ length: 7 }, (_, index) => makeAttachment(`held-${index}`)); + const existing = Array.from({ length: 99 }, (_, index) => makeAttachment(`held-${index}`)); appAtomRegistry.set(composerDraftsAtom, { [draftKey]: { text: "send this", attachments: existing }, }); @@ -918,7 +918,7 @@ describe("mobile composer drafts", () => { expect(rejected).toBe(1); const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey]; - expect(draft?.attachments).toHaveLength(8); + expect(draft?.attachments).toHaveLength(100); expect(draft?.attachments.at(-1)?.id).toBe("incoming-1"); await cleanup.promise; expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith( @@ -932,7 +932,7 @@ describe("mobile composer drafts", () => { { allowOverflow: true }, ); expect(overflowRejected).toBe(0); - expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(9); + expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(101); }); it("keeps shared attachment files until every draft releases them", async () => { @@ -2270,7 +2270,7 @@ describe("mobile composer drafts", () => { previewUri: "data:image/png;base64,YWJj", }); const existingImage = image("existing"); - const sharedImages = Array.from({ length: 8 }, (_, index) => image(`shared-${index}`)); + const sharedImages = Array.from({ length: 100 }, (_, index) => image(`shared-${index}`)); const merged = mergeComposerDraftContentState( { [draftKey]: { text: "", attachments: [existingImage] } }, @@ -2278,9 +2278,9 @@ describe("mobile composer drafts", () => { { text: "", attachments: sharedImages }, ); - expect(merged[draftKey]?.attachments).toHaveLength(8); + expect(merged[draftKey]?.attachments).toHaveLength(100); expect(merged[draftKey]?.attachments[0]).toEqual(existingImage); - expect(merged[draftKey]?.attachments.at(-1)?.id).toBe("shared-6"); + expect(merged[draftKey]?.attachments.at(-1)?.id).toBe("shared-98"); }); it("restores the exact draft captured before an interrupted share import", () => { diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 7262239577b4..2c747fc71310 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -8,7 +8,11 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import { RPC_REQUIRED_SCOPES, requiredScopeForRpcMethod } from "./RpcAuthorization.ts"; +import { + RPC_REQUIRED_SCOPES, + requiredScopeForRpcMethod, + requiredScopeForDeviceList, +} from "./RpcAuthorization.ts"; describe("RPC authorization scopes", () => { it("declares exactly one scope for every RPC in the server group", () => { @@ -71,3 +75,17 @@ describe("RPC authorization scopes", () => { } }); }); + +it("requires operate permission for host retry while preserving read-only listing", () => { + expect(requiredScopeForDeviceList({})).toBe(AuthOrchestrationReadScope); + expect(requiredScopeForDeviceList({ retryHostId: "remote-host" })).toBe( + AuthOrchestrationOperateScope, + ); +}); + +it("requires operate permission for tool updates even alongside a read-only check", () => { + expect(requiredScopeForDeviceList({ updateTool: "agent", inspectOnly: true })).toBe( + AuthOrchestrationOperateScope, + ); + expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope); +}); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 20f2893b8335..68a167083762 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -1,4 +1,5 @@ import { + type DeviceListInput, AuthAccessReadScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, @@ -180,3 +181,9 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope } return requiredScope; } + +/** Retrying can install or restart tools even though ordinary listing is readable. */ +export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope => + input.retryHostId || input.updateTool + ? AuthOrchestrationOperateScope + : AuthOrchestrationReadScope; diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 795bb05db148..9bc20fff84e1 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -15,6 +15,7 @@ import { } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; @@ -101,10 +102,10 @@ const makeCliTestServerConfig = (baseDir: string) => otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", mode: "web", port: 0, host: "127.0.0.1", diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index aea753c3398f..34ea7f685364 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -17,6 +17,7 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { deriveServerPaths } from "../config.ts"; import { resolveServerConfig } from "./config.ts"; @@ -51,10 +52,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", devAllowedOrigins: [], } as const; @@ -764,7 +765,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ), ); - expect(resolved.otlpHeaders).toEqual({ + expect(resolved.otlpTracesExport.headers).toEqual({ authorization: "Basic abc==", "x-tenant": "t3", }); @@ -808,7 +809,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ), ); - expect(resolved.otlpHeaders).toEqual({ + expect(resolved.otlpTracesExport.headers).toEqual({ authorization: "Bearer abc==", "x-tenant": "t3", }); @@ -816,7 +817,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); - it.effect("reads the OTLP protocol from env", () => + it.effect("gives every signal the protocol named without one", () => Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-protocol-base"); @@ -848,7 +849,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ), ); - expect(resolved.otlpProtocol).toBe("http/protobuf"); + expect([ + resolved.otlpTracesExport.protocol, + resolved.otlpMetricsExport.protocol, + resolved.otlpLogsExport.protocol, + ]).toEqual(["http/protobuf", "http/protobuf", "http/protobuf"]); }), ); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 32f465b6fb0a..09a30aeb19e7 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -1,5 +1,9 @@ import * as NetService from "@t3tools/shared/Net"; -import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability"; +import { + OtlpHeadersFromString, + OtlpProtocol, + type SignalExport, +} from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; @@ -382,6 +386,14 @@ export const resolveServerConfig = ( ); const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); + // T3 Code's own OTLP variables name no signal, so the one answer they give + // is the answer for all three. + const signalExport: SignalExport = { + protocol: env.otlpProtocol, + headers: env.otlpHeaders, + exportIntervalMs: env.otlpExportIntervalMs, + }; + const config: ServerConfig.ServerConfig["Service"] = { logLevel, traceMinLevel: env.traceMinLevel, @@ -399,10 +411,10 @@ export const resolveServerConfig = ( persistedObservabilitySettings.otlpMetricsUrl, otlpLogsUrl: env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl, - otlpExportIntervalMs: env.otlpExportIntervalMs, + otlpTracesExport: signalExport, + otlpMetricsExport: signalExport, + otlpLogsExport: signalExport, otlpServiceName: env.otlpServiceName, - otlpHeaders: env.otlpHeaders, - otlpProtocol: env.otlpProtocol, mode, port, cwd, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 9ff9a8e13b39..493e6b719416 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -15,6 +15,7 @@ import { PortSchema, } from "@t3tools/contracts"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import { buildTailscaleHttpsBaseUrl, DEFAULT_TAILSCALE_SERVE_PORT, @@ -321,10 +322,10 @@ const makePairServerConfig = Effect.fn(function* (input: { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", mode: "web", port: state.port, host: state.host, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 4835ebb40e44..5762ccdb6ff3 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -17,7 +17,7 @@ import type * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; -import { OtlpProtocol } from "@t3tools/shared/observability"; +import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability"; export const DEFAULT_PORT = 3773; @@ -73,10 +73,15 @@ export class ServerConfig extends Context.Service< readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; readonly otlpLogsUrl: string | undefined; - readonly otlpExportIntervalMs: number; + /** + * How each signal is exported. Read instead of a process-wide setting so + * the wire format, credential, and schedule travel with the endpoint they + * were configured beside. + */ + readonly otlpTracesExport: SignalExport; + readonly otlpMetricsExport: SignalExport; + readonly otlpLogsExport: SignalExport; readonly otlpServiceName: string; - readonly otlpHeaders: Readonly> | undefined; - readonly otlpProtocol: OtlpProtocol; readonly mode: RuntimeMode; readonly port: number; readonly host: string | undefined; @@ -212,10 +217,10 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", cwd, baseDir, ...derivedPaths, diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index a947f8dc9e68..14537985b9b8 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -80,6 +80,7 @@ export class DeviceHost extends Context.Service< { readonly id: DeviceHostId; readonly summary: Effect.Effect; + readonly inspect?: Effect.Effect; readonly platformAvailability: ( platform: DevicePlatform, ) => Effect.Effect; @@ -88,11 +89,11 @@ export class DeviceHost extends Context.Service< * concurrent callers share one start, and a ready host returns immediately. */ readonly ensureReady: ( - onPhase: (phase: "installing" | "starting") => Effect.Effect, + onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ) => Effect.Effect; /** Installs and starts agent-device after the user grants agent access. */ readonly ensureAgentReady: ( - onPhase: (phase: "installing" | "starting") => Effect.Effect, + onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ) => Effect.Effect< DeviceHostAgentReady, DeviceHostError | DeviceHostTimeoutError | NodeRuntimeUnavailableError diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index f1ed9a7f0253..7a81d757c0ac 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -2,15 +2,18 @@ import { describe, expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, DeviceId, + DeviceOperationError, LOCAL_DEVICE_HOST_ID, ThreadId, type DeviceServiceState, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerSettingsService } from "../serverSettings.ts"; @@ -19,6 +22,8 @@ import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts"; +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + const baseState: DeviceServiceState = { hosts: [], hostStatus: "idle", @@ -62,7 +67,9 @@ const fixture = Effect.fn("fixture")(function* ( onBoot: Effect.Effect = Effect.void, bootError?: string, failListAfterShutdown = false, - runtimeFailure?: NodeRuntimeUnavailableError, + runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, + inspectError = false, + installTool?: Parameters[3], ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); const starts: string[] = []; @@ -78,6 +85,17 @@ const fixture = Effect.fn("fixture")(function* ( run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }), }; const host: DeviceHost.DeviceHost["Service"] = { + ...(inspectError + ? { + inspect: Effect.fail( + new DeviceHost.DeviceHostError({ + hostId: LOCAL_DEVICE_HOST_ID, + step: "probe", + cause: new Error("offline"), + }), + ), + } + : {}), id: LOCAL_DEVICE_HOST_ID, summary: Effect.succeed({ id: LOCAL_DEVICE_HOST_ID, @@ -92,7 +110,7 @@ const fixture = Effect.fn("fixture")(function* ( Effect.gen(function* () { if (runtimeFailure) return yield* runtimeFailure; starts.push("start"); - yield* onPhase("starting"); + yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…"); return ready; }), ensureAgentReady: (onPhase) => @@ -113,7 +131,12 @@ const fixture = Effect.fn("fixture")(function* ( starts.push("stop"); }), }; - const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe( + const service = yield* makeWithHosts( + new Map([[host.id, host]]), + undefined, + undefined, + installTool, + ).pipe( Effect.provideService(DeviceHost.DeviceHost, host), Effect.provideService( ServerSettingsService, @@ -378,3 +401,333 @@ it.effect("keeps shutdown successful when subsequent discovery fails", () => expect(state.devices.find((device) => device.id === session.deviceId)?.booted).toBe(false); }).pipe(Effect.scoped), ); + +it.effect.each(["shutdown", "close"] as const)( + "%s releases iOS capture so reopening uses a fresh session", + (operation) => + Effect.gen(function* () { + const deviceId = DeviceId.make("11111111-1111-1111-1111-111111111111"); + const threadId = ThreadId.make("capture-recovery"); + let booted = true; + let capture: number | null = null; + let generation = 0; + const ready: DeviceHost.DeviceHostReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + run: () => Effect.succeed({ code: 0, stdout: "", stderr: "" }), + }; + const host: DeviceHost.DeviceHost["Service"] = { + id: LOCAL_DEVICE_HOST_ID, + summary: Effect.succeed({ + id: LOCAL_DEVICE_HOST_ID, + kind: "local", + label: "Simulator host", + platforms: [{ platform: "ios", available: true }], + hubInstalled: true, + agentDeviceInstalled: false, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => Effect.succeed(ready), + ensureAgentReady: () => Effect.die("Agent access is not used in this test"), + current: Effect.succeed(ready), + stopAgent: Effect.void, + stop: Effect.void, + }; + const http = HttpClient.make((request) => + Effect.sync(() => { + const path = new URL(request.url).pathname; + if (path === "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/api/devices") { + return HttpClientResponse.fromWeb( + request, + Response.json({ + emulators: [], + simulators: [ + { + id: deviceId, + name: "iPhone", + platform: "ios", + version: "26", + physical: false, + booted, + }, + ], + }), + ); + } + if (path === "/vendor/serve-sim/grid/api/start") capture ??= ++generation; + else if (path === "/vendor/serve-sim/grid/api/shutdown") { + if (request.body._tag !== "Uint8Array") throw new Error("Missing shutdown body"); + expect(decodeJson(new TextDecoder().decode(request.body.body))).toEqual({ + udid: deviceId, + }); + capture = null; + booted = false; + } else if (path === "/api/devices/shutdown") { + // This route powers off without releasing serve-sim's cached capture. + booted = false; + } else if (path === "/api/devices/boot") booted = true; + else throw new Error(`Unexpected hub path: ${path}`); + return HttpClientResponse.fromWeb(request, Response.json({ ok: true, id: deviceId })); + }), + ); + const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); + const input = { threadId, deviceId, platform: "ios" as const }; + yield* service.open(input); + expect(capture).toBe(1); + if (operation === "shutdown") yield* service.shutdown(input); + else yield* service.close({ threadId, deviceId, shutdown: true }); + expect(capture).toBeNull(); + expect((yield* service.state).sessions).toEqual([]); + yield* service.open(input); + expect(capture).toBe(2); + expect((yield* service.state).sessions).toHaveLength(1); + }).pipe( + Effect.provide(ServerSettingsService.layerTest({ enableDeviceSupport: true })), + Effect.scoped, + ), +); + +it.effect.each([ + { hubReports: "off", outcome: "succeeds" }, + { hubReports: "booted", outcome: "fails" }, + { hubReports: "missing", outcome: "fails" }, +] as const)( + "iOS shutdown $outcome when serve-sim rejects it and the hub reports the simulator $hubReports", + ({ hubReports, outcome }) => + Effect.gen(function* () { + const deviceId = DeviceId.make("22222222-2222-2222-2222-222222222222"); + const paths: string[] = []; + // The device list is stale until shutdown re-reads it from the hub. + let listed: "booted" | "off" | "missing" = "booted"; + const ready: DeviceHost.DeviceHostReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + run: () => Effect.succeed({ code: 0, stdout: "", stderr: "" }), + }; + const host: DeviceHost.DeviceHost["Service"] = { + id: LOCAL_DEVICE_HOST_ID, + summary: Effect.succeed({ + id: LOCAL_DEVICE_HOST_ID, + kind: "local", + label: "Simulator host", + platforms: [{ platform: "ios", available: true }], + hubInstalled: true, + agentDeviceInstalled: false, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => Effect.succeed(ready), + ensureAgentReady: () => Effect.die("Agent access is not used in this test"), + current: Effect.succeed(ready), + stopAgent: Effect.void, + stop: Effect.void, + }; + const http = HttpClient.make((request) => + Effect.sync(() => { + const path = new URL(request.url).pathname; + paths.push(path); + if (path === "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/api/devices") { + return HttpClientResponse.fromWeb( + request, + Response.json({ + emulators: [], + simulators: + listed === "missing" + ? [] + : [ + { + id: deviceId, + name: "iPhone", + platform: "ios", + version: "26", + physical: false, + booted: listed === "booted", + }, + ], + // A partial listing still decodes; it must not read as "off". + errors: listed === "missing" ? [{ message: "simctl list failed" }] : [], + }), + ); + } + if (path === "/vendor/serve-sim/grid/api/shutdown") { + // serve-sim runs `simctl shutdown` bare and returns its failure as-is. + listed = hubReports; + return HttpClientResponse.fromWeb( + request, + Response.json( + { ok: false, error: "Unable to shutdown device in current state: Shutdown" }, + { status: 500 }, + ), + ); + } + throw new Error(`Unexpected hub path: ${path}`); + }), + ); + const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); + yield* service.list; + const exit = yield* Effect.exit(service.shutdown({ deviceId, platform: "ios" })); + expect(paths.filter((path) => path.endsWith("shutdown"))).toEqual([ + "/vendor/serve-sim/grid/api/shutdown", + ]); + if (outcome === "succeeds") { + expect(Exit.isSuccess(exit)).toBe(true); + expect( + (yield* service.state).devices.find((device) => device.id === deviceId)?.booted, + ).toBe(false); + } else { + expect(Exit.isFailure(exit)).toBe(true); + expect( + (yield* service.state).devices.find((device) => device.id === deviceId)?.booted, + ).toBe(true); + } + }).pipe( + Effect.provide(ServerSettingsService.layerTest({ enableDeviceSupport: true })), + Effect.scoped, + ), +); + +it.effect("retry keeps device and agent consent unchanged", () => + Effect.gen(function* () { + const { service, starts, agentStarts } = yield* fixture(); + yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + yield* service.configure({ enabled: true }); + yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(agentStarts).toEqual([]); + yield* service.configure({ agentAccessEnabled: true }); + const before = agentStarts.length; + yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(agentStarts.length).toBe(before + 1); + }).pipe(Effect.scoped), +); + +it.effect("publishes update detail for the correct host", () => + Effect.gen(function* () { + const { service } = yield* fixture(); + const changes = yield* service.subscribe; + yield* service.configure({ enabled: true }); + const states = yield* PubSub.takeAll(changes); + expect( + states.some( + (state) => state.hostStatuses.local?.detail === "Updating device hub from 0.9.0 to 0.10.1…", + ), + ).toBe(true); + }).pipe(Effect.scoped), +); + +it.effect("host retry exposes actionable failure without internal IDs or diagnostics", () => + Effect.gen(function* () { + const { service, settings } = yield* fixture( + Effect.void, + undefined, + false, + new DeviceHost.DeviceHostError({ + hostId: LOCAL_DEVICE_HOST_ID, + step: "probe", + cause: "private diagnostics", + }), + ); + yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true })); + const state = yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(state.supportsHostRetry).toBe(true); + expect(state.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ + status: "failed", + detail: "Could not connect to this host over SSH.", + }); + }).pipe(Effect.scoped), +); + +it.effect("version discovery does not grant consent or start device tools", () => + Effect.gen(function* () { + const { service, starts, agentStarts, requests } = yield* fixture(); + const state = yield* service.inspect; + expect(state.supportsToolInspection).toBe(true); + expect(state.hostStatus).toBe("disabled"); + expect(state.hosts).toHaveLength(1); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + expect(requests).toEqual([]); + }).pipe(Effect.scoped), +); + +it.effect("failed read-only discovery preserves lifecycle status and installed inventory", () => + Effect.gen(function* () { + const { service, starts } = yield* fixture(Effect.void, undefined, false, undefined, true); + const state = yield* service.inspect; + expect(state.supportsToolInspection).toBe(true); + expect(state.hostStatus).toBe("disabled"); + expect(state.hosts[0]?.hubInstalled).toBe(true); + expect(state.hosts[0]?.toolInspectionError).toContain("Reconnect the host"); + expect(starts).toEqual([]); + }).pipe(Effect.scoped), +); + +it.effect( + "manual updates install only the selected tool without enabling access or starting helpers", + () => + Effect.gen(function* () { + const installed: string[] = []; + const { service, starts, agentStarts, requests } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + (tool) => + Effect.sync(() => { + installed.push(tool); + }), + ); + const before = yield* service.state; + const state = yield* service.updateTool("agent"); + expect(installed).toEqual(["agent"]); + expect(state.supportsToolUpdate).toBe(true); + expect(state.hostStatus).toBe(before.hostStatus); + expect(state.agentAccessEnabled).toBe(before.agentAccessEnabled); + expect(state.revision).toBeGreaterThan(before.revision); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + expect(requests).toEqual([]); + yield* service.updateTool("hub"); + expect(installed).toEqual(["agent", "hub"]); + }).pipe(Effect.scoped), +); + +it.effect("failed manual installation leaves lifecycle state unchanged and can be retried", () => + Effect.gen(function* () { + let attempts = 0; + const { service, starts, agentStarts } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + () => + Effect.suspend(() => + ++attempts === 1 + ? Effect.fail( + new DeviceOperationError({ + operation: "update device tool", + reason: "command_failed", + cause: new Error("offline"), + }), + ) + : Effect.void, + ), + ); + const before = yield* service.state; + const result = yield* service.updateTool("agent").pipe(Effect.result); + expect(result._tag).toBe("Failure"); + expect(yield* service.state).toEqual(before); + yield* service.updateTool("agent"); + expect(attempts).toBe(2); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 30e1f18c0497..2435fbca34ab 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -38,7 +38,7 @@ import { import * as FileSystem from "effect/FileSystem"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Path from "effect/Path"; -import { ensureAgentDevice } from "./DeviceToolchain.ts"; +import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts"; import * as ServerConfig from "../config.ts"; import { agentDeviceConfigPath, @@ -126,6 +126,9 @@ export class DeviceService extends Context.Service< ) => Effect.Effect; /** Refreshes devices only after device support has been enabled. */ readonly list: Effect.Effect; + readonly updateTool: (tool: "hub" | "agent") => Effect.Effect; + readonly inspect: Effect.Effect; + readonly retryHost: (hostId: DeviceHostId) => Effect.Effect; readonly open: (input: DeviceOpenInput) => Effect.Effect; readonly close: (input: DeviceCloseInput) => Effect.Effect; readonly shutdown: (input: DeviceShutdownInput) => Effect.Effect; @@ -180,6 +183,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* reason: "Agent configuration is unavailable in this device service.", }), ), + installTool?: (tool: "hub" | "agent") => Effect.Effect, ) { const settings = yield* ServerSettings.ServerSettingsService; const lifecycleLock = yield* Semaphore.make(1); @@ -202,6 +206,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* let publishedHosts = new Map(hosts); const stateRef = yield* SynchronizedRef.make({ state: { + supportsHostRetry: true, + supportsToolUpdate: installTool !== undefined, + supportsToolInspection: true, hosts: initialHosts, hostStatus: initialSettings.enabled ? "idle" : "disabled", hostStatuses: {}, @@ -257,7 +264,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }); } const ready = yield* host - .ensureReady((status) => setHostStatus(host.id, { status }).pipe(Effect.asVoid)) + .ensureReady((status, detail) => + setHostStatus(host.id, { status, detail }).pipe(Effect.asVoid), + ) .pipe( Effect.tapError((error) => setHostStatus(host.id, { status: "failed", detail: error.message }), @@ -269,7 +278,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* reason: error._tag === "NodeRuntimeUnavailableError" ? nodeRuntimeUnavailableMessage("Local device support") - : `Device host ${error.hostId} failed while ${error.step}.`, + : error.step === "probe" + ? "Could not connect to this host over SSH." + : `Device support failed during ${error.step}.`, cause: error, }), ), @@ -308,7 +319,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) return null; const ready = yield* host - .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid)) + .ensureAgentReady((phase, detail) => + setHostStatus(host.id, { status: phase, detail }).pipe(Effect.asVoid), + ) .pipe( Effect.tapError((error) => setHostStatus(host.id, { status: "failed", detail: error.message }), @@ -321,8 +334,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* error._tag === "NodeRuntimeUnavailableError" ? nodeRuntimeUnavailableMessage("Local device support") : error._tag === "DeviceHostTimeoutError" - ? `Device host ${error.hostId} did not start agent tools within ${error.timeoutMs} ms.` - : `Device host ${error.hostId} failed while ${error.step}.`, + ? `Agent tools did not start within ${error.timeoutMs} ms.` + : error.step === "probe" + ? "Could not connect to this host over SSH." + : `Device support failed during ${error.step}.`, cause: error, }), ), @@ -444,7 +459,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (ready) yield* refresh(ready); }).pipe( Effect.catch((error) => - setHostStatus(host.id, { status: "failed", detail: error.message }), + setHostStatus(host.id, { + status: "failed", + detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message, + }), ), ), { concurrency: 4 }, @@ -452,6 +470,58 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return (yield* SynchronizedRef.get(stateRef)).state; }).pipe(Effect.withSpan("DeviceService.list")); + const inspect = Effect.gen(function* () { + yield* Effect.forEach( + hosts.values(), + (host) => + Effect.gen(function* () { + const result = yield* (host.inspect ?? host.summary).pipe(Effect.result); + if (hosts.get(host.id) !== host) return; + if (result._tag === "Failure") { + yield* publish((state) => ({ + ...state, + hosts: state.hosts.map((value) => + value.id === host.id + ? { + ...value, + toolInspectionError: + "Cannot check versions. Reconnect the host and check again. Installed tools have not been changed.", + } + : value, + ), + })); + return; + } + yield* publish((state) => ({ + ...state, + hosts: state.hosts.map((value) => (value.id === host.id ? result.success : value)), + })); + }), + { concurrency: 4 }, + ); + return (yield* SynchronizedRef.get(stateRef)).state; + }); + + const retryHost: DeviceService["Service"]["retryHost"] = Effect.fn("DeviceService.retryHost")( + function* (hostId) { + yield* resolveHost(hostId); + if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state; + yield* Effect.gen(function* () { + const ready = + (yield* agentReadinessIfSupported(hostId)) ?? (yield* readinessIfSupported(hostId)); + if (ready) yield* refresh(ready); + }).pipe( + Effect.catch((error) => + setHostStatus(hostId, { + status: "failed", + detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message, + }), + ), + ); + return (yield* SynchronizedRef.get(stateRef)).state; + }, + ); + const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")( function* (input) { const currentSettings = yield* readDeviceSettings; @@ -670,25 +740,44 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* platform: DevicePlatform, ) { const ready = yield* readiness(hostId); - yield* HttpClientRequest.post(`${ready.hub.origin}/api/devices/shutdown`).pipe( - HttpClientRequest.bodyJson({ platform, id: deviceId }), - Effect.mapError( - (cause) => - new DeviceOperationError({ operation: "shutdown", reason: "invalid_payload", cause }), - ), - Effect.flatMap((request) => hubJson(request, HubActionResult, "shutdown")), - Effect.flatMap((result) => - result.ok - ? Effect.void - : Effect.fail( - new DeviceOperationError({ - operation: "shutdown", - reason: "hub_rejected", - cause: result, - }), + const postShutdown = (path: string, body: Record) => + HttpClientRequest.post(`${ready.hub.origin}${path}`).pipe( + HttpClientRequest.bodyJson(body), + Effect.mapError( + (cause) => + new DeviceOperationError({ operation: "shutdown", reason: "invalid_payload", cause }), + ), + Effect.flatMap((request) => hubJson(request, HubActionResult, "shutdown")), + Effect.flatMap((result) => + result.ok + ? Effect.void + : Effect.fail( + new DeviceOperationError({ + operation: "shutdown", + reason: "hub_rejected", + cause: result, + }), + ), + ), + ); + // serve-sim's shutdown closes its in-process capture session before it runs + // `simctl shutdown`; the hub's generic shutdown can leave that session cached + // across a reboot. serve-sim runs simctl bare, though, so a simulator that is + // already off fails there. Accept that failure only when the hub confirms + // the simulator is off; a failure on a running one still surfaces. + yield* platform === "ios" + ? postShutdown(`${vendorPrefix("ios")}/grid/api/shutdown`, { udid: deviceId }).pipe( + Effect.catch((cause) => + fetchDevices(ready).pipe( + Effect.flatMap(({ devices }) => + devices.find((device) => device.id === deviceId)?.booted === false + ? Effect.logInfo("iOS simulator was already shut down", { deviceId }) + : Effect.fail(cause), + ), ), - ), - ); + ), + ) + : postShutdown("/api/devices/shutdown", { platform, id: deviceId }); yield* publish((state) => ({ ...state, devices: state.devices.map((device) => @@ -813,6 +902,23 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return { ...DeviceService.of({ testHost, + updateTool: (tool) => + lifecycleLock.withPermit( + Effect.gen(function* () { + if (!installTool) + return yield* Effect.fail( + new DeviceOperationError({ + operation: "update device tool", + reason: "request_failed", + cause: new Error("Tool installation is unavailable in this device service."), + }), + ); + yield* installTool(tool); + return yield* inspect; + }), + ), + retryHost, + inspect, agentCli: Effect.fail( new DeviceHostUnavailableError({ hostId: LOCAL_DEVICE_HOST_ID, @@ -933,6 +1039,20 @@ export const make = Effect.gen(function* () { ), ), configureAgent, + (tool) => + (tool === "hub" ? ensureDeviceHub(config.baseDir) : ensureAgentDevice(config.baseDir)).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.mapError( + (cause) => + new DeviceOperationError({ + operation: "update device tool", + reason: "command_failed", + cause, + }), + ), + ), ); const hostContext = yield* Effect.context>>(); @@ -1023,7 +1143,7 @@ export const make = Effect.gen(function* () { return { ...service, agentCli: resolveNodeExecutable("Device automation").pipe( - Effect.flatMap(() => ensureAgentDevice(config.baseDir)), + Effect.andThen(ensureAgentDevice(config.baseDir)), Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), diff --git a/apps/server/src/device/DeviceToolchain.test.ts b/apps/server/src/device/DeviceToolchain.test.ts index 9f70ca91a8e1..d7812a92b48e 100644 --- a/apps/server/src/device/DeviceToolchain.test.ts +++ b/apps/server/src/device/DeviceToolchain.test.ts @@ -1,3 +1,4 @@ +import * as PlatformError from "effect/PlatformError"; import { expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -6,7 +7,12 @@ import * as Path from "effect/Path"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; -import { ensureDeviceHub, isDeviceHubInstalled } from "./DeviceToolchain.ts"; +import { + deviceToolVersions, + DEVICE_HUB_VERSION, + ensureDeviceHub, + isDeviceHubInstalled, +} from "./DeviceToolchain.ts"; it.effect("failed installation cleans staging and exposes only a safe failure message", () => Effect.gen(function* () { @@ -37,3 +43,57 @@ it.effect("failed installation cleans staging and exposes only a safe failure me expect(yield* fs.readDirectory(path.join(baseDir, "tools", "expo-device-hub"))).toEqual([]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("inventory reports only completed versions without installing the required version", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fs.makeTempDirectoryScoped(); + for (const [version, sentinel] of [ + ["0.9.0", "0.9.0"], + [DEVICE_HUB_VERSION, "wrong"], + [".staging-123", ".staging-123"], + ]) { + const dir = path.join(base, "tools", "expo-device-hub", version!); + yield* fs.makeDirectory(path.join(dir, "node_modules/expo-device-hub/dist/server"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(dir, "node_modules/expo-device-hub/dist/server/cli.mjs"), + "", + ); + yield* fs.writeFileString(path.join(dir, ".install-complete"), sentinel!); + } + const tools = yield* deviceToolVersions(base); + expect(tools?.hub).toEqual({ + requiredVersion: DEVICE_HUB_VERSION, + installedVersions: ["0.9.0"], + runningVersion: null, + }); + expect(tools?.agent.installedVersions).toEqual([]); + expect(yield* isDeviceHubInstalled(base)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("unreadable inventory stays unknown instead of reporting no installs", () => + Effect.gen(function* () { + const tools = yield* deviceToolVersions("/unreadable"); + expect(tools).toBeUndefined(); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readDirectory: () => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readDirectory", + description: "denied", + }), + ), + }), + ), + Effect.provide(NodeServices.layer), + ), +); diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index 4d59cb462383..81cdb79217fe 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -1,3 +1,4 @@ +import type { DeviceToolVersions } from "@t3tools/contracts"; /** * Pinned installs of the two external tools device support is built on. * @@ -25,9 +26,9 @@ import * as Semaphore from "effect/Semaphore"; import * as ProcessRunner from "../processRunner.ts"; const DEVICE_HUB_PACKAGE = "expo-device-hub"; -export const DEVICE_HUB_VERSION = "0.9.0"; +export const DEVICE_HUB_VERSION = "0.10.1"; const AGENT_DEVICE_PACKAGE = "agent-device"; -export const AGENT_DEVICE_VERSION = "0.20.10"; +export const AGENT_DEVICE_VERSION = "0.21.7"; const INSTALL_TIMEOUT = Duration.minutes(10); const installLock = Semaphore.makeUnsafe(1); @@ -221,3 +222,46 @@ export const isDeviceHubInstalled = (baseDir: string) => export const isAgentDeviceInstalled = (baseDir: string) => isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice); + +/** Read completed installs without downloading or starting either tool. */ +export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function* ( + baseDir: string, + running: { hub?: string; agent?: string } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const inspect = Effect.fn("DeviceToolchain.inspect")(function* (spec: ToolSpec) { + const directory = path.join(baseDir, "tools", spec.name); + const names = yield* fs.readDirectory(directory).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed([]), + ), + ); + const versions = yield* Effect.filter(names, (version) => + /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version) + ? Effect.gen(function* () { + const paths = toolPaths(path, baseDir, { ...spec, version }); + const sentinel = yield* fs.readFileString(paths.sentinelPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(null), + ), + ); + return sentinel?.trim() === version && (yield* fs.exists(paths.entryPath)); + }) + : Effect.succeed(false), + ); + return { + requiredVersion: spec.version, + installedVersions: versions.sort(), + runningVersion: (spec.name === DEVICE_HUB_PACKAGE ? running.hub : running.agent) ?? null, + }; + }); + return yield* Effect.gen(function* () { + return { + hub: yield* inspect(HUB_SPEC), + agent: yield* inspect(AGENT_DEVICE_SPEC), + } satisfies DeviceToolVersions; + }).pipe(Effect.orElseSucceed(() => undefined)); +}); diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index b7341e04f8ad..e704d28bbc34 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -1,3 +1,5 @@ +import { pruneLocalDeviceTools } from "./deviceToolMaintenance.ts"; +import { deviceToolInstallMessage } from "@t3tools/contracts"; /** * The device host that is this machine. * @@ -31,6 +33,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -50,6 +53,8 @@ import { ensureDeviceHub, isAgentDeviceInstalled, isDeviceHubInstalled, + deviceToolVersions, + DEVICE_HUB_VERSION, } from "./DeviceToolchain.ts"; const HUB_READY_TIMEOUT_MS = 30_000; @@ -76,6 +81,7 @@ const AgentDeviceDaemonFile = Schema.Struct({ httpPort: Schema.Int, token: Schema.String, pid: Schema.optional(Schema.Int), + version: Schema.optional(Schema.String), }); const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); @@ -232,7 +238,26 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(Path.Path, path), ), ]); + const running = yield* Ref.get(runningRef); + const daemon = running?.agentDevice + ? yield* readDaemonFile().pipe(Effect.option) + : Option.none(); + const hubAlive = running + ? yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false)) + : false; + const agentAlive = + Option.isSome(daemon) && daemon.value.pid ? yield* isProcessAlive(daemon.value.pid) : false; + const tools = yield* deviceToolVersions(config.baseDir, { + ...(hubAlive ? { hub: DEVICE_HUB_VERSION } : {}), + ...(agentAlive && Option.isSome(daemon) && daemon.value.version + ? { agent: daemon.value.version } + : {}), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); return { + tools, id: hostId, kind: "local", label: "This machine", @@ -533,7 +558,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null; const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( - onPhase: (phase: "installing" | "starting") => Effect.Effect, + onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ): Effect.fn.Return { const running = yield* Ref.get(runningRef); if (running) { @@ -550,7 +575,10 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); - if (!installed) yield* onPhase("installing"); + if (!installed) { + const inventory = yield* summary; + yield* onPhase("installing", deviceToolInstallMessage("device hub", inventory.tools?.hub)); + } const hubTool = yield* ensureDeviceHub(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -566,6 +594,11 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ); yield* onPhase("starting"); const hub = yield* spawnHub(hubTool, nodePath); + yield* pruneLocalDeviceTools(config.baseDir, nodePath, "hub").pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.ignore, + ); const candidate = helperPaths(hubTool); const [axExists, cliExists] = yield* Effect.all([ fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)), @@ -605,7 +638,13 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); - if (!installed) yield* onPhase("installing"); + if (!installed) { + const inventory = yield* summary; + yield* onPhase( + "installing", + deviceToolInstallMessage("agent tools", inventory.tools?.agent), + ); + } const agentTool = yield* ensureAgentDevice(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -622,6 +661,11 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath }; yield* onPhase("starting"); const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath); + yield* pruneLocalDeviceTools(config.baseDir, running.hub.nodePath, "agent").pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.ignore, + ); const next = { ...running, agentDevice }; yield* Ref.set(runningRef, next); return { ...toReady(next), agentDevice }; diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index d968d3383fae..71a15190ddc8 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -20,6 +20,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ const fs = yield* FileSystem.FileSystem; const home = yield* fs.makeTempDirectoryScoped(); const modes: string[] = []; + const owners: string[] = []; let forwards = 0; let failForward = true; let rejectConfig = true; @@ -62,6 +63,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ ); const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? ""; modes.push(mode); + owners.push(/const owner = "([^"]+)"/.exec(script)?.[1] ?? ""); output = JSON.stringify({ nodePath: "/node", platforms: [{ platform: "ios", available: true }], @@ -110,6 +112,12 @@ it.effect("preserves installed status after probes and cleans failed agent activ ), ); yield* host.ensureReady(() => Effect.void); + yield* SshDeviceHost.probe({ id: "test", label: "Test", target: "test.example" }).pipe( + Effect.provide(ServerConfig.layerTest(home, home)), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + expect(new Set(owners).size).toBe(1); + expect(owners[0]).toMatch(/^[a-f0-9]{24}$/); expect(forwards).toBe(1); expect(modes.filter((mode) => mode === "start")).toHaveLength(2); yield* host.platformAvailability("ios"); diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index 4ccda0fdefe9..4ed6d740215c 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -2,6 +2,8 @@ import * as NodeCrypto from "node:crypto"; import { type DeviceHostSummary, DevicePlatformAvailability, + DeviceToolVersions, + deviceToolInstallMessage, type SshDeviceHostConfig, } from "@t3tools/contracts"; import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; @@ -24,6 +26,7 @@ import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./s const Probe = Schema.Struct({ nodePath: Schema.String, + tools: Schema.optional(DeviceToolVersions), platforms: Schema.Array(DevicePlatformAvailability), }); const Started = Schema.Struct({ @@ -70,8 +73,23 @@ const bootstrap = ( ), ); -export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) { - const result = yield* bootstrap(config, "probe", "probe"); +const ownerFor = Effect.fn("SshDeviceHost.ownerFor")(function* (hostId: string) { + const fs = yield* FileSystem.FileSystem; + const server = yield* ServerConfig.ServerConfig; + const environmentId = yield* fs + .readFileString(server.environmentIdPath) + .pipe(Effect.orElseSucceed(() => server.stateDir)); + return NodeCrypto.createHash("sha256") + .update(`${environmentId}\0${server.stateDir}\0${hostId}`) + .digest("hex") + .slice(0, 24); +}); + +export const probe = Effect.fn("SshDeviceHost.probe")(function* ( + config: SshDeviceHostConfig, + owner?: string, +) { + const result = yield* bootstrap(config, owner ?? (yield* ownerFor(config.id)), "probe"); const value = yield* decodeProbe(result.stdout.trim()).pipe( Effect.mapError( (cause) => @@ -82,8 +100,11 @@ export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDevi id: config.id, label: config.label, kind: "ssh", - hubInstalled: false, - agentDeviceInstalled: false, + tools: value.tools, + hubInstalled: + value.tools?.hub.installedVersions.includes(value.tools.hub.requiredVersion) ?? false, + agentDeviceInstalled: + value.tools?.agent.installedVersions.includes(value.tools.agent.requiredVersion) ?? false, platforms: value.platforms, } satisfies DeviceHostSummary; }); @@ -106,23 +127,21 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const parentScope = yield* Scope.Scope; const ssh = yield* resolveSshCommand; - const environmentId = yield* fs - .readFileString(server.environmentIdPath) - .pipe(Effect.orElseSucceed(() => server.stateDir)); - const owner = NodeCrypto.createHash("sha256") - .update(`${environmentId}\0${server.stateDir}\0${config.id}`) - .digest("hex") - .slice(0, 24); + const owner = yield* ownerFor(config.id); const provide = ( effect: Effect.Effect< A, E, - FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | ServerConfig.ServerConfig >, ) => effect.pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), + Effect.provideService(ServerConfig.ServerConfig, server), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const lock = yield* Semaphore.make(1); @@ -183,6 +202,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( summary = { ...summary, platforms: remote.platforms, + tools: remote.tools, hubInstalled: true, agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled, }; @@ -351,8 +371,13 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( Effect.gen(function* () { stopped = false; if (ready) return ready; - summary = yield* provide(probe(config)); - yield* onPhase("installing"); + summary = yield* provide(probe(config, owner)); + yield* onPhase( + summary.hubInstalled ? "starting" : "installing", + summary.hubInstalled + ? undefined + : deviceToolInstallMessage("device hub", summary.tools?.hub), + ); return yield* connect().pipe( Effect.tapError(() => connectionScope ? Scope.close(connectionScope, Exit.void) : Effect.void, @@ -399,10 +424,25 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( return { id: config.id, summary: Effect.sync(() => summary), + inspect: provide(probe(config, owner)).pipe( + Effect.tap((value) => + Effect.sync(() => { + summary = value; + }), + ), + ), current: Effect.sync(() => ready), ensureReady, ensureAgentReady: (onPhase) => - onPhase("installing").pipe( + ensureReady(onPhase).pipe( + Effect.flatMap(() => + onPhase( + summary.agentDeviceInstalled ? "starting" : "installing", + summary.agentDeviceInstalled + ? undefined + : deviceToolInstallMessage("agent tools", summary.tools?.agent), + ), + ), Effect.flatMap(() => changeAgent(true)), Effect.flatMap((value) => value?.agentDevice @@ -419,7 +459,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore), stop, platformAvailability: (platform) => - provide(probe(config)).pipe( + provide(probe(config, owner)).pipe( Effect.map((value) => { summary = { ...summary, platforms: value.platforms }; return value.platforms.find((p) => p.platform === platform)!; diff --git a/apps/server/src/device/deviceToolMaintenance.test.ts b/apps/server/src/device/deviceToolMaintenance.test.ts new file mode 100644 index 000000000000..4c46be11bba4 --- /dev/null +++ b/apps/server/src/device/deviceToolMaintenance.test.ts @@ -0,0 +1,159 @@ +import * as Effect from "effect/Effect"; +import * as NodePathLayer from "@effect/platform-node/NodePath"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +// @effect-diagnostics nodeBuiltinImport:off - tests the same standalone script used by local and SSH hosts. +import { describe, expect, it } from "@effect/vitest"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeUtil from "node:util"; +import { pruneLocalDeviceTools, deviceToolMaintenanceScript } from "./deviceToolMaintenance.ts"; + +const exec = NodeUtil.promisify(NodeChildProcess.execFile); + +describe.each([false, true])("device tool cleanup, flat=%s", (flat) => { + it("keeps current, previous, active and incomplete installs, pruning unused completed versions", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-cleanup-")); + const name = "expo-device-hub"; + const directory = (version: string) => + flat ? NodePath.join(root, `${name}@${version}`) : NodePath.join(root, name, version); + try { + for (const version of ["0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0"]) { + await NodeFSP.mkdir(directory(version), { recursive: true }); + if (version === "0.5.0") continue; + const sentinel = NodePath.join(directory(version), ".install-complete"); + await NodeFSP.writeFile(sentinel, version); + await NodeFSP.utimes( + sentinel, + Number(version.split(".")[1]), + Number(version.split(".")[1]), + ); + } + const script = + deviceToolMaintenanceScript + + ` +(async () => { + const root = ${JSON.stringify(root)}; + await pruneTools(root, [['${name}', '0.6.0']], ${flat}); +})().catch(error => { console.error(error); process.exitCode = 1; });`; + await exec(process.execPath, [ + "-e", + script, + NodePath.join(directory("0.2.0"), "active-helper.cjs"), + ]); + await expect(NodeFSP.stat(directory("0.1.0"))).rejects.toThrow(); + await expect(NodeFSP.stat(directory("0.3.0"))).rejects.toThrow(); + for (const version of ["0.2.0", "0.4.0", "0.5.0", "0.6.0"]) + expect((await NodeFSP.stat(directory(version))).isDirectory()).toBe(true); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); + + it("keeps every install when the process scan fails", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-scan-")); + try { + for (const version of ["0.1.0", "0.2.0", "0.3.0"]) { + const dir = flat + ? NodePath.join(root, `expo-device-hub@${version}`) + : NodePath.join(root, "expo-device-hub", version); + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(dir, ".install-complete"), version); + } + await exec(process.execPath, [ + "-e", + deviceToolMaintenanceScript + + ` + require('node:child_process').spawnSync = () => ({ status: 1, stdout: '' }); + pruneTools(${JSON.stringify(root)}, [['expo-device-hub', '0.3.0']], ${flat}).catch(() => process.exitCode = 1); + `, + ]); + const parent = flat ? root : NodePath.join(root, "expo-device-hub"); + expect((await NodeFSP.readdir(parent)).length).toBe(3); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); + + it("does not prune before the required version has completed installation", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-cleanup-")); + try { + const dir = flat + ? NodePath.join(root, "expo-device-hub@0.1.0") + : NodePath.join(root, "expo-device-hub/0.1.0"); + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(dir, ".install-complete"), "0.1.0"); + await exec(process.execPath, [ + "-e", + deviceToolMaintenanceScript + + `pruneTools(${JSON.stringify(root)}, [['expo-device-hub','0.6.0']], ${flat}).catch(() => process.exitCode = 1);`, + ]); + expect((await NodeFSP.stat(dir)).isDirectory()).toBe(true); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); +}); + +it.effect("maintenance failures retain safe context and the original process result", () => + Effect.gen(function* () { + const output = { + code: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "private child diagnostics", + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + for (const [operation, run] of [["prune", pruneLocalDeviceTools]] as const) { + const error = yield* run("/tools", process.execPath, "hub").pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { run: () => Effect.succeed(output) }), + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "DeviceToolMaintenanceError", + operation, + tool: "hub", + exitCode: 1, + cause: output, + }); + expect(error.message).toBe(`Device tool ${operation} failed for hub (exit code 1).`); + expect(error.message).not.toContain(output.stderr); + } + }).pipe(Effect.provide(NodePathLayer.layer)), +); + +it("serializes competing maintenance processes after reclaiming a stale lock", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-contention-")); + try { + const lock = NodePath.join(root, ".maintenance-lock"); + await NodeFSP.mkdir(lock); + await NodeFSP.writeFile( + NodePath.join(lock, "stale-owner.json"), + JSON.stringify({ pid: 2147483647, identity: "dead" }), + ); + const script = + deviceToolMaintenanceScript + + ` +(async () => { + const root = ${JSON.stringify(root)}; + const marker = maintenancePath.join(root, 'critical-section'); + for (let attempt = 0; attempt < 8; attempt++) await withToolMaintenance(root, () => { + maintenanceFs.writeFileSync(marker, String(process.pid), { flag: 'wx' }); + for (let check = 0; check < 100; check++) { + if (maintenanceFs.readFileSync(marker, 'utf8') !== String(process.pid)) throw Error('Overlapping maintenance'); + } + maintenanceFs.unlinkSync(marker); + }); +})().catch(error => { console.error(error); process.exitCode = 1; });`; + await Promise.all(Array.from({ length: 6 }, () => exec(process.execPath, ["-e", script]))); + await expect(NodeFSP.stat(lock)).rejects.toThrow(); + expect(await NodeFSP.readdir(root)).toEqual([]); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/server/src/device/deviceToolMaintenance.ts b/apps/server/src/device/deviceToolMaintenance.ts new file mode 100644 index 000000000000..43cc64e8faca --- /dev/null +++ b/apps/server/src/device/deviceToolMaintenance.ts @@ -0,0 +1,148 @@ +// @effect-diagnostics preferSchemaOverJson:off - JSON string literals safely embed paths and arguments in generated JavaScript. +import * as Schema from "effect/Schema"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as ProcessRunner from "../processRunner.ts"; +import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; + +/** Shared with the SSH bootstrap. Cleanup runs only after successful startup. */ +export const deviceToolMaintenanceScript = String.raw` +const maintenanceFs = require('node:fs'); +const maintenancePath = require('node:path'); +const maintenanceAlive = pid => { + try { process.kill(pid, 0); return true; } + catch (error) { return error.code !== 'ESRCH'; } +}; +async function withToolMaintenance(root, operation) { + maintenanceFs.mkdirSync(root, { recursive: true }); + const lock = maintenancePath.join(root, '.maintenance-lock'); + const nonce = require('node:crypto').randomUUID(); + const ownerFile = process.pid + '.' + nonce + '.json'; + const candidate = lock + '.' + nonce; + const holder = { pid: process.pid }; + const deadline = Date.now() + 30000; + const removeEmptyLock = () => { + try { maintenanceFs.rmdirSync(lock); } + catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EPERM'].includes(error.code)) throw error; } + }; + maintenanceFs.mkdirSync(candidate); + try { + maintenanceFs.writeFileSync(maintenancePath.join(candidate, ownerFile), JSON.stringify(holder)); + while (true) { + try { + // Publish a populated directory atomically; rename cannot replace another populated lock. + maintenanceFs.renameSync(candidate, lock); + break; + } + catch (error) { + if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code)) throw error; + let files = []; + try { files = maintenanceFs.readdirSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; } + if (files.length === 1) { + const previousFile = maintenancePath.join(lock, files[0]); + let previous; + try { previous = JSON.parse(maintenanceFs.readFileSync(previousFile, 'utf8')); } catch {} + if (Number.isSafeInteger(previous?.pid) && previous.pid > 0 && !maintenanceAlive(previous.pid)) { + // The unique filename belongs only to that owner. Never unlink a replacement owner's file. + try { maintenanceFs.unlinkSync(previousFile); } catch (error) { if (error.code !== 'ENOENT') throw error; } + } + } + // A concurrent acquirer publishes its owner file with the directory, so this cannot remove it. + removeEmptyLock(); + if (Date.now() >= deadline) throw Error('Device tool maintenance is locked. Retry when the other operation finishes.'); + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + try { return operation(); } + finally { + maintenanceFs.unlinkSync(maintenancePath.join(lock, ownerFile)); + removeEmptyLock(); + } + } finally { + maintenanceFs.rmSync(candidate, { recursive: true, force: true }); + } +} +function pruneTools(root, specs, flat) { + return withToolMaintenance(root, () => { + // Keep installs used by any running helper, including older T3 releases. + const scan = process.platform === 'win32' + ? require('node:child_process').spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine'], { encoding: 'utf8', timeout: 10000 }) + : require('node:child_process').spawnSync('ps', ['-ax', '-o', 'command='], { encoding: 'utf8', timeout: 10000 }); + if (scan.status !== 0 || !scan.stdout) return; + for (const [name, required] of specs) { + const parent = flat ? root : maintenancePath.join(root, name); + let names; + try { names = maintenanceFs.readdirSync(parent); } catch { continue; } + const completed = []; + for (const item of names) { + const version = flat ? (item.startsWith(name + '@') ? item.slice(name.length + 1) : '') : item; + if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) continue; + const directory = maintenancePath.join(parent, item); + try { + if (!maintenanceFs.lstatSync(directory).isDirectory()) continue; + if (maintenanceFs.readFileSync(maintenancePath.join(directory, '.install-complete'), 'utf8').trim() !== version) continue; + completed.push({ version, directory, modified: maintenanceFs.statSync(maintenancePath.join(directory, '.install-complete')).mtimeMs }); + } catch {} + } + // Never prune until the required install has completed. Retain the last other successful install. + if (!completed.some(value => value.version === required)) continue; + const previous = completed.filter(value => value.version !== required).sort((a, b) => b.modified - a.modified || b.version.localeCompare(a.version, 'en', { numeric: true }))[0]?.version; + for (const { version, directory } of completed) { + if (version === required || version === previous || scan.stdout.includes(directory + maintenancePath.sep)) continue; + maintenanceFs.rmSync(directory, { recursive: true, force: true }); + } + } + }); +} +`; + +class DeviceToolMaintenanceError extends Schema.TaggedError()( + "DeviceToolMaintenanceError", + { + operation: Schema.Literal("prune"), + tool: Schema.Literals(["hub", "agent"]), + exitCode: Schema.NullOr(Schema.Int), + cause: Schema.Defect(), + }, +) { + override get message() { + return `Device tool ${this.operation} failed for ${this.tool} (exit code ${this.exitCode ?? "unknown"}).`; + } +} + +const runMaintenance = Effect.fn("DeviceToolchain.maintenance")(function* ( + nodePath: string, + script: string, + operation: "prune", + tool: "hub" | "agent", +) { + const runner = yield* ProcessRunner.ProcessRunner; + const result = yield* runner.run({ + command: nodePath, + args: [ + "-e", + deviceToolMaintenanceScript + + "\n" + + script + + ".catch(error => { console.error(error.message); process.exitCode = 1; });", + ], + }); + if (result.code !== 0) + return yield* Effect.fail( + new DeviceToolMaintenanceError({ operation, tool, exitCode: result.code, cause: result }), + ); +}); + +export const pruneLocalDeviceTools = Effect.fn("DeviceToolchain.prune")(function* ( + baseDir: string, + nodePath: string, + tool: "hub" | "agent", +) { + const path = yield* Path.Path; + yield* runMaintenance( + nodePath, + `pruneTools(${JSON.stringify(path.join(baseDir, "tools"))}, ${JSON.stringify(tool === "hub" ? [["expo-device-hub", DEVICE_HUB_VERSION]] : [["agent-device", AGENT_DEVICE_VERSION]])}, false)`, + "prune", + tool, + ); +}); diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index eadd85fd047d..8d9bc77d891c 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -83,15 +83,16 @@ const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir const file=path.join(state,'daemon.json'); if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); fs.writeFileSync(path.join(state,'stopped-agent'),String(data.pid)); try {process.kill(data.pid,'SIGTERM')} catch {} } else if(args[0]==='serve') { const server=http.createServer((req,res)=>{res.statusCode=fs.existsSync(path.join(state,'unhealthy-agent-'+process.pid))?503:200;res.end('ok');}); server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(file,JSON.stringify({httpPort:server.address().port,pid:process.pid,token:'test'}));process.send?.('ready');process.disconnect?.();}); } -else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); } +else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1]),'daemon.mjs'),'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); } `, ); + await NodeFSP.copyFile(agent, NodePath.join(NodePath.dirname(agent), "daemon.mjs")); const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade"; const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade"; let invocation = 0; const invoke = async ( owner: string, - mode: "start" | "agent-start" | "stop-agent" | "stop", + mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", upgraded = false, ) => { const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`); @@ -107,6 +108,11 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr }); return result.stdout ? JSON.parse(result.stdout) : null; }; + const inventory = await invoke("one", "probe"); + expect(inventory.tools.hub.installedVersions).toEqual([DEVICE_HUB_VERSION]); + expect(inventory.tools.hub.runningVersion).toBeNull(); + expect(inventory.tools.agent.installedVersions).toEqual([AGENT_DEVICE_VERSION]); + await expect(NodeFSP.stat(NodePath.join(root, "hosts/one/hub.json"))).rejects.toThrow(); const template = NodePath.join(home, "hub-template"); await NodeFSP.cp(hubDir, template, { recursive: true }); await NodeFSP.rm(NodePath.join(hubDir, ".install-complete")); @@ -114,11 +120,14 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr await NodeFSP.symlink("2147483647:exited-installer", installLock); await NodeFSP.writeFile( NodePath.join(bin, "npm"), - `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`, + `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);if(args[0]==='--version'){console.log('10.0.0');process.exit(0);}fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`, { mode: 0o755 }, ); await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), ""); + // Unavailable advisory bookkeeping must not prevent either helper from starting. + await NodeFSP.writeFile(NodePath.join(root, "tools/.maintenance-lock"), "blocked"); + await NodeFSP.writeFile(NodePath.join(root, "tools/.users"), "unwritable lease directory"); try { const [manual, concurrent] = await Promise.all([ invoke("one", "start"), @@ -135,6 +144,9 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr ]); expect(concurrentAgent.hubPort).toBe(first.hubPort); expect(concurrentAgent.daemonPort).toBe(first.daemonPort); + const running = await invoke("one", "probe"); + expect(running.tools.hub.runningVersion).toBe(DEVICE_HUB_VERSION); + expect(running.tools.agent.runningVersion).toBe(AGENT_DEVICE_VERSION); const second = await invoke("two", "agent-start"); const reused = await invoke("one", "agent-start"); expect(reused.hubPort).toBe(first.hubPort); diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index bbdb828c1a74..de412b5873d5 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -1,3 +1,4 @@ +import { deviceToolMaintenanceScript } from "./deviceToolMaintenance.ts"; import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; export const quoteRemoteArg = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`; @@ -28,6 +29,7 @@ const mode = ${JSON.stringify(mode)}; const hubVersion = ${JSON.stringify(DEVICE_HUB_VERSION)}; const agentVersion = ${JSON.stringify(AGENT_DEVICE_VERSION)}; ` + + deviceToolMaintenanceScript + String.raw` const fs = require('node:fs'); const path = require('node:path'); @@ -39,6 +41,35 @@ const state = path.join(root, 'hosts', owner); const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options }); const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }; const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); }; +const toolVersions = (name, requiredVersion, entry, record) => { + const directory = path.join(root, 'tools'); + const prefix = name + '@'; + let names = []; + try { names = fs.readdirSync(directory); } catch (error) { if (error.code !== 'ENOENT') return null; } + let unreadable = false; + const installedVersions = names.filter(name => name.startsWith(prefix)).map(name => name.slice(prefix.length)).filter(version => { + if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) return false; + const dir = path.join(directory, prefix + version); + try { return fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version && fs.existsSync(path.join(dir, 'node_modules', name, entry)); } catch (error) { if (error.code !== 'ENOENT') unreadable = true; return false; } + }).sort(); + if (unreadable) return null; + let runningVersion = null; + if (record?.entryPath && record?.pid) { + const command = run('ps', ['-p', String(record.pid), '-o', 'command=']).stdout || ''; + runningVersion = installedVersions.find(version => { + const install = path.join(directory, prefix + version); + return record.entryPath === path.join(install, 'node_modules', name, entry) && command.includes(install + path.sep); + }) ?? null; + } + return { requiredVersion, installedVersions, runningVersion }; +}; +const versions = () => { + const result = { + hub: toolVersions('expo-device-hub', hubVersion, 'dist/server/cli.mjs', read(path.join(state, 'hub.json'))), + agent: toolVersions('agent-device', agentVersion, 'bin/agent-device.mjs', { ...read(path.join(state, 'agent.json')), ...read(path.join(state, 'daemon.json')) }), + }; + return result.hub && result.agent ? result : undefined; +}; const stopHub = hub => { if (!hub || hub.owner !== owner) return; const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || ''; @@ -111,7 +142,7 @@ async function install(name, version, entry) { if (mode === 'probe') { if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.'); if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.'); - console.log(JSON.stringify({ nodePath: process.execPath, platforms })); return; + console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions() })); return; } fs.mkdirSync(state, { recursive: true, mode: 0o700 }); // Serialize starts and stops for this environment/host owner, including agent startup. @@ -185,7 +216,8 @@ async function install(name, version, entry) { } const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist'); const optional = file => fs.existsSync(file) ? file : null; - console.log(JSON.stringify({ nodePath: process.execPath, platforms, hubPort: hub.port, ...agentResult, + await pruneTools(path.join(root, 'tools'), [['expo-device-hub', hubVersion], ...(mode === 'agent-start' ? [['agent-device', agentVersion]] : [])], true).catch(() => {}); + console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions(), hubPort: hub.port, ...agentResult, helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); } finally { releaseHost(); } })().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 0ccf8de691eb..b4758e980065 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -10,6 +10,8 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; + import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { PUBLISH_AGENT_ACTIVITY_SECRET, @@ -54,10 +56,10 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", cwd: process.cwd(), baseDir, mode: "web", diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 033cc192ccfe..820c6a781f8b 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -2543,11 +2543,20 @@ export const make = Effect.gen(function* () { }); } - const worktree = yield* gitCore.createWorktree({ - cwd: input.cwd, - refName: localPullRequestBranch, - path: null, - }); + const worktree = yield* gitCore.createWorktree( + { + cwd: input.cwd, + refName: localPullRequestBranch, + path: null, + }, + { + // Best effort: a settings read failure falls back to the checkout's t3.json. + submodules: yield* projectSettingsFor(input).pipe( + Effect.map((settings) => settings.worktreeSubmodules), + Effect.orElseSucceed(() => null), + ), + }, + ); yield* ensureExistingWorktreeUpstream(worktree.worktree.path); yield* maybeRunSetupScript(worktree.worktree.path); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 879ca66ca60a..7533b8c1db19 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -322,7 +322,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( const request = yield* HttpServerRequest.HttpServerRequest; const config = yield* ServerConfig.ServerConfig; const otlpTracesUrl = config.otlpTracesUrl; - const otlpHeaders = config.otlpHeaders; + const otlpHeaders = config.otlpTracesExport.headers; const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector; const httpClient = yield* HttpClient.HttpClient; const serialization = yield* OtlpSerialization.OtlpSerialization; diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 0c9acdfb1ee3..8f7b607745f4 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -20,7 +20,11 @@ import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; export const ObservabilityLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - const serializationLayer = otlpSerializationLayer(config.otlpProtocol); + const traces = config.otlpTracesExport; + const metrics = config.otlpMetricsExport; + // The trace serializer stays in the returned context because the browser + // trace forwarder exports on the same signal. + const serializationLayer = otlpSerializationLayer(traces.protocol); const resource = ServerConfig.otlpResource(config); const attribution = yield* ResourceAttribution.ResourceAttribution; @@ -51,8 +55,8 @@ export const ObservabilityLive = Layer.unwrap( ? undefined : yield* OtlpTracer.make({ url: config.otlpTracesUrl, - exportInterval: `${config.otlpExportIntervalMs} millis`, - headers: config.otlpHeaders, + exportInterval: `${traces.exportIntervalMs} millis`, + headers: traces.headers, resource, }); @@ -77,10 +81,10 @@ export const ObservabilityLive = Layer.unwrap( ? Layer.empty : OtlpMetrics.layer({ url: config.otlpMetricsUrl, - exportInterval: `${config.otlpExportIntervalMs} millis`, - headers: config.otlpHeaders, + exportInterval: `${metrics.exportIntervalMs} millis`, + headers: metrics.headers, resource, - }).pipe(Layer.provideMerge(serializationLayer)); + }).pipe(Layer.provide(otlpSerializationLayer(metrics.protocol))); return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer); }), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 9bc701af0837..c7d9417bc75b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2637,11 +2637,14 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); expect(harness.pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-project" }); - expect(harness.createWorktree).toHaveBeenCalledWith({ - cwd: "/tmp/provider-project", - refName: "feature/restore", - path: worktreePath, - }); + expect(harness.createWorktree).toHaveBeenCalledWith( + { + cwd: "/tmp/provider-project", + refName: "feature/restore", + path: worktreePath, + }, + { submodules: null }, + ); expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan( harness.startSession.mock.invocationCallOrder[0]!, ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 6a42b7c67ae1..bdf4fe8e69d9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -501,8 +501,16 @@ const make = Effect.gen(function* () { }); // A directory deleted without `git worktree remove` leaves an admin entry // that makes `git worktree add` refuse the path; prune clears it. + // Best effort like the rest of this recovery: a settings read failure + // falls back to the checkout's t3.json. + const submodules = yield* projectSettingsForThread(thread.id).pipe( + Effect.map((settings) => settings.worktreeSubmodules), + Effect.orElseSucceed(() => null), + ); yield* gitWorkflow.pruneWorktrees({ cwd }).pipe( - Effect.andThen(gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath })), + Effect.andThen( + gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath }, { submodules }), + ), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) diff --git a/apps/server/src/orchestration/Normalizer.attachments.test.ts b/apps/server/src/orchestration/Normalizer.attachments.test.ts index 51c70d7401ae..d44ec58e6a95 100644 --- a/apps/server/src/orchestration/Normalizer.attachments.test.ts +++ b/apps/server/src/orchestration/Normalizer.attachments.test.ts @@ -13,6 +13,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -59,6 +60,53 @@ function turnStartCommand(input: { } describe("normalizeDispatchCommand attachments", () => { + it.effect("accepts 100 inline images and rejects 101 before writing files", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const attachments = Array.from({ length: 100 }, () => ({ + dataUrl: "data:image/png;base64,cGl4ZWxz", + sizeBytes: 6, + })); + const rejected = yield* normalizeDispatchCommand( + turnStartCommand({ attachments: [...attachments, attachments[0]!] }), + ).pipe(Effect.flip); + expect(rejected.message).toContain("up to 100"); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + const accepted = yield* normalizeDispatchCommand(turnStartCommand({ attachments })); + if (accepted.type !== "thread.turn.start") throw new Error("Wrong command"); + expect(accepted.message.attachments).toHaveLength(100); + expect(NodeFS.readdirSync(config.attachmentsDir)).toHaveLength(100); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects decoded image overflow before writing it and removes earlier files", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + let writtenBytes = 0; + const dataUrl = `data:image/png;base64,${Buffer.alloc(10 * 1024 * 1024).toString("base64")}`; + const command = turnStartCommand({ + attachments: [ + ...Array.from({ length: 8 }, () => ({ dataUrl, sizeBytes: 1 })), + { dataUrl: "data:image/png;base64,YQ==", sizeBytes: 0 }, + ], + }); + const error = yield* normalizeDispatchCommand(command).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + writeFile: (path, data, options) => { + writtenBytes += data.byteLength; + return fileSystem.writeFile(path, data, options); + }, + }), + Effect.flip, + ); + expect(error.message).toContain("80 MiB"); + expect(writtenBytes).toBe(80 * 1024 * 1024); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("rejects duplicate client ids before persisting attachments", () => Effect.gen(function* () { const error = yield* normalizeDispatchCommand( @@ -464,25 +512,25 @@ describe("question attachments", () => { answers: { first: "", second: "" }, createdAt: "2026-08-01T00:00:00.000Z", attachmentsByQuestionId: { - first: Array.from({ length: 4 }, () => attachment), - second: Array.from({ length: 5 }, () => attachment), + first: Array.from({ length: 50 }, () => attachment), + second: Array.from({ length: 51 }, () => attachment), }, }; const failure = yield* normalizeDispatchCommand(command).pipe(Effect.flip); - expect(failure.message).toContain("up to 8"); + expect(failure.message).toContain("up to 100"); expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${id}.txt`]); const accepted = { ...command, attachmentsByQuestionId: { ...command.attachmentsByQuestionId, - second: Array.from({ length: 4 }, () => attachment), + second: Array.from({ length: 50 }, () => attachment), }, }; const normalized = yield* normalizeDispatchCommand(accepted); if (normalized.type !== "thread.user-input.respond") throw new Error("Wrong command"); const attachments = Object.values(normalized.attachmentsByQuestionId!).flat(); - expect(attachments).toHaveLength(8); - expect(new Set(attachments.map((item) => item.id)).size).toBe(8); + expect(attachments).toHaveLength(100); + expect(new Set(attachments.map((item) => item.id)).size).toBe(100); for (const item of attachments) { expect(item.name).toBe(attachment.name); expect( diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index bef58adc0581..d4959f39af59 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -5,7 +5,7 @@ import * as Path from "effect/Path"; import { type ClientOrchestrationCommand, type UserInputAttachments, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + getProviderAttachmentLimitError, type IsoDateTime, type OrchestrationCommand, OrchestrationDispatchCommandError, @@ -142,13 +142,9 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => canonicalCommand.type === "thread.turn.start" ? canonicalCommand.message.attachments : Object.values(canonicalCommand.attachmentsByQuestionId ?? {}).flat(); - if ( - canonicalCommand.type === "thread.user-input.respond" && - attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS - ) { - return yield* new OrchestrationDispatchCommandError({ - message: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per question response.`, - }); + const attachmentLimitError = getProviderAttachmentLimitError(attachments); + if (attachmentLimitError) { + return yield* new OrchestrationDispatchCommandError({ message: attachmentLimitError }); } if (canonicalCommand.type === "thread.turn.start") { const clientAttachmentIds = new Set(); @@ -163,11 +159,12 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => } } const claimedAttachmentPaths: string[] = []; + const attachmentsWithDecodedSizes = [...attachments]; // Context records bind to attachments by the id the client knew; they follow the rename. const finalAttachmentIdByClientId = new Map(); const normalizedAttachments = yield* Effect.forEach( attachments, - (attachment) => + (attachment, index) => Effect.gen(function* () { if (!("dataUrl" in attachment)) { const claim = planAttachmentClaim({ @@ -259,6 +256,11 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => sizeBytes: bytes.byteLength, ...(attachment.source ? { source: attachment.source } : {}), }; + attachmentsWithDecodedSizes[index] = persistedAttachment; + const decodedLimitError = getProviderAttachmentLimitError(attachmentsWithDecodedSizes); + if (decodedLimitError) { + return yield* new OrchestrationDispatchCommandError({ message: decodedLimitError }); + } const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, @@ -286,6 +288,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => }), ), ); + claimedAttachmentPaths.push(attachmentPath); if (attachment.id !== undefined) { finalAttachmentIdByClientId.set(attachment.id, attachmentId); } diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 96d6b10d3839..601917d35864 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -7155,27 +7155,27 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const values = Map.prototype.values; yield* Effect.acquireRelease( Effect.sync(() => - vi - .spyOn(Map.prototype, "values") - .mockImplementation(function (this: Map) { - const iterator = values.call(this); - const next = iterator.next.bind(iterator); - iterator.next = () => { - const result = next(); - const value: unknown = result.value; - if ( - typeof value === "object" && - value !== null && - "id" in value && - typeof value.id === "string" && - value.id.startsWith("history-part-") - ) { - visitedHistoryParts += 1; - } - return result; - }; - return iterator; - }), + vi.spyOn(Map.prototype, "values").mockImplementation(function ( + this: Map, + ) { + const iterator = values.call(this); + const next = iterator.next.bind(iterator); + iterator.next = () => { + const result = next(); + const value: unknown = result.value; + if ( + typeof value === "object" && + value !== null && + "id" in value && + typeof value.id === "string" && + value.id.startsWith("history-part-") + ) { + visitedHistoryParts += 1; + } + return result; + }; + return iterator; + }), ), (spy) => Effect.sync(() => spy.mockRestore()), ); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 9d563d3965c3..a0d111bd710f 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3404,6 +3404,67 @@ it.effect("a listing narrowed to some projects is its own cache entry", () => }), ); +it.effect( + "keeps listing freshness tied to read start when filtered reads finish out of order", + () => + Effect.gen(function* () { + const olderStarted = yield* Deferred.make(); + const releaseOlder = yield* Deferred.make(); + let reads = 0; + const updatedAt = "2026-07-02T00:00:00Z"; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: ({ filters }) => + Effect.gen(function* () { + reads += 1; + const older = filters?.checks === "failing"; + if (older) { + yield* Deferred.succeed(olderStarted, undefined); + yield* Deferred.await(releaseOlder); + } + return { + items: [ + { + ...changeRequest(1, updatedAt), + checksState: older ? ("failing" as const) : ("passing" as const), + mergeability: older ? ("mergeable" as const) : ("conflicting" as const), + }, + ], + truncated: false, + continues: false, + }; + }), + }), + ], + }); + const olderInput = { state: "open" as const, filters: { checks: "failing" as const } }; + const newerInput = { state: "open" as const, filters: { checks: "passing" as const } }; + + const olderRead = yield* service.list(olderInput).pipe(Effect.forkChild()); + yield* Deferred.await(olderStarted); + yield* TestClock.adjust("1 second"); + const newer = yield* service.list(newerInput); + yield* Deferred.succeed(releaseOlder, undefined); + const older = yield* Fiber.join(olderRead); + + assert.strictEqual(older.entries[0]?.checksState, "failing"); + assert.strictEqual(older.entries[0]?.mergeability, "mergeable"); + assert.strictEqual(newer.entries[0]?.checksState, "passing"); + assert.strictEqual(newer.entries[0]?.mergeability, "conflicting"); + assert.strictEqual(typeof older.entries[0]?.observedAt, "number"); + assert.strictEqual(typeof newer.entries[0]?.observedAt, "number"); + assert.isBelow(older.entries[0]!.observedAt!, newer.entries[0]!.observedAt!); + + const cachedOlder = yield* service.list(olderInput); + assert.strictEqual(cachedOlder.entries[0]?.observedAt, older.entries[0]?.observedAt); + assert.strictEqual(reads, 2); + }), +); + it.effect("keeps unrelated PRs warm after a mutation, explicit refresh, and project turn", () => Effect.gen(function* () { const calls: string[] = []; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a653ef94c713..6b4b2bd8bfd5 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -611,6 +611,12 @@ function withRateLimitBackoff( Record, never>; } +// Capture before the provider read so a slow response keeps its original freshness through caches. +const observeRead = Effect.fnUntraced(function* (read: Effect.Effect) { + const observedAt = yield* Clock.currentTimeMillis; + return { value: yield* read, observedAt }; +}); + export const make = Effect.gen(function* () { const mergedPullRequests = yield* PubSub.sliding(64); const pullRequestRefreshes = yield* SubscriptionRef.make(0); @@ -1076,6 +1082,7 @@ export const make = Effect.gen(function* () { readonly project: SupportedProject; readonly item: ProviderChangeRequest; readonly viewer: string; + readonly observedAt: number; }): PullRequestListEntry => { const viewer = input.viewer.toLowerCase(); return { @@ -1098,6 +1105,7 @@ export const make = Effect.gen(function* () { deletions: input.item.deletions, createdAt: input.item.createdAt, updatedAt: input.item.updatedAt, + observedAt: input.observedAt, ...(input.item.checksState === undefined || input.item.checksState === null ? {} : { checksState: input.item.checksState }), @@ -1246,7 +1254,8 @@ export const make = Effect.gen(function* () { }), }) .pipe( - Effect.map((page): RepositoryBatch => { + observeRead, + Effect.map(({ value: page, observedAt }): RepositoryBatch => { // The boundary instant was asked for inclusively, so the rows already sent at it // come back with the slice. Dropping them here rather than asking for strictly // older is what keeps their neighbours at the same instant from being skipped. @@ -1262,7 +1271,7 @@ export const make = Effect.gen(function* () { key, entries: items .filter((item) => matchesRowFilters(item, input.filters, viewer)) - .map((item) => toEntry({ project, item, viewer })), + .map((item) => toEntry({ project, item, viewer, observedAt })), errors: [], truncated: page.truncated, nextCursor: @@ -1323,7 +1332,8 @@ export const make = Effect.gen(function* () { ? {} : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }), }).pipe( - Effect.flatMap((page) => + observeRead, + Effect.flatMap(({ value: page, observedAt }) => Effect.flatMap(Clock.currentTimeMillis, (now) => { const rows = new Map>(); for (const [key, visibleAt] of searchVisibleAt) { @@ -1384,7 +1394,7 @@ export const make = Effect.gen(function* () { key: project.cursorKey, entries: items .filter((item) => matchesRowFilters(item, input.filters, viewer)) - .map((item) => toEntry({ project, item, viewer })), + .map((item) => toEntry({ project, item, viewer, observedAt })), errors: [], truncated: page.truncated, nextCursor: @@ -1551,7 +1561,8 @@ export const make = Effect.gen(function* () { : project.api.getChangeRequestSummary(providerInput); return read.pipe( Effect.mapError(toPullRequestError("summary")), - Effect.map((changeRequest): PullRequestSummary => ({ + observeRead, + Effect.map(({ value: changeRequest, observedAt }): PullRequestSummary => ({ provider: project.api.kind, projectId: project.project.id, repository: project.repository, @@ -1564,6 +1575,7 @@ export const make = Effect.gen(function* () { closedAt: changeRequest.closedAt ?? null, mergedAt: changeRequest.mergedAt ?? null, updatedAt: changeRequest.updatedAt, + observedAt, ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }), ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }), ...(changeRequest.additions === undefined @@ -1634,12 +1646,12 @@ export const make = Effect.gen(function* () { host: project.host, number: input.number, }) - .pipe(Effect.mapError(toPullRequestError("detail"))), + .pipe(Effect.mapError(toPullRequestError("detail")), observeRead), viewerOf(project), ], { concurrency: 2 }, ).pipe( - Effect.map(([changeRequest, viewer]): PullRequestDetail => ({ + Effect.map(([{ value: changeRequest, observedAt }, viewer]): PullRequestDetail => ({ provider: project.api.kind, capabilities: project.api.capabilities, projectId: project.project.id, @@ -1664,6 +1676,7 @@ export const make = Effect.gen(function* () { baseBranch: changeRequest.baseBranch, createdAt: changeRequest.createdAt, updatedAt: changeRequest.updatedAt, + observedAt, mergedAt: changeRequest.mergedAt, closedAt: changeRequest.closedAt, reviewers: changeRequest.reviewers, @@ -2894,12 +2907,14 @@ export const make = Effect.gen(function* () { closedAt: detail.closedAt, mergedAt: detail.mergedAt, updatedAt: detail.updatedAt, + observedAt: detail.observedAt, }); const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => { const current = lastGoodSummary.peek(key); if (current === undefined) return true; if (current.state === "merged" && next.state !== "merged") return false; - return next.updatedAt >= current.updatedAt; + if (next.updatedAt !== current.updatedAt) return next.updatedAt > current.updatedAt; + return (next.observedAt ?? -Infinity) >= (current.observedAt ?? -Infinity); }; const detail: PullRequestService["Service"]["detail"] = (input) => { const key = refCacheKey(input); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 4591ac0437bf..81033e2e0d94 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -97,6 +97,40 @@ describe("pull request list decoding", () => { ]); }); + it("takes the verdict from the latest reviews when GitHub summarizes none, as for a bot's approval", () => { + const batch = expectSuccess( + decodePullRequestListJson( + listJson([ + { + reviewDecision: null, + latestReviews: [{ author: { login: "macroscopeapp" }, state: "APPROVED" }], + }, + { + reviewDecision: "REVIEW_REQUIRED", + latestReviews: [ + { author: { login: "octocat" }, state: "APPROVED" }, + { author: { login: "hubot" }, state: "CHANGES_REQUESTED" }, + ], + }, + { + reviewDecision: "APPROVED", + latestReviews: [{ author: { login: "hubot" }, state: "CHANGES_REQUESTED" }], + }, + { + reviewDecision: null, + latestReviews: [{ author: { login: "octocat" }, state: "COMMENTED" }], + }, + ]), + ), + ); + expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([ + "approved", + "changes-requested", + "approved", + null, + ]); + }); + it("rolls the head commit's checks up to the one word a row has space for", () => { const batch = expectSuccess( decodePullRequestListJson( @@ -121,6 +155,13 @@ describe("pull request list decoding", () => { { statusCheckRollup: [{ context: "ci/legacy", state: "ERROR" }] }, // Neither a pass, a failure nor a wait is no verdict rather than a green tick. { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SKIPPED" }] }, + // Cancelled reads as failing here and in the detail header, so the two never flap. + { + statusCheckRollup: [ + { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }, + { name: "test", status: "COMPLETED", conclusion: "CANCELLED" }, + ], + }, { statusCheckRollup: [] }, {}, ]), @@ -132,6 +173,7 @@ describe("pull request list decoding", () => { "passing", "failing", null, + "failing", null, null, ]); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index a011702d685e..eaaa4e34da84 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -68,6 +68,12 @@ const RawReviewRequestSchema = Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)), }); +/** One reviewer's most recent review: the state is all the verdict needs, the author is for who. */ +const RawLatestReviewSchema = Schema.Struct({ + author: Schema.optional(Schema.NullOr(RawActorSchema)), + state: Schema.optional(Schema.NullOr(Schema.String)), +}); + const RawCheckSchema = Schema.Struct({ __typename: Schema.optional(Schema.String), name: Schema.optional(Schema.NullOr(Schema.String)), @@ -106,6 +112,7 @@ const RawListItemSchema = Schema.Struct({ updatedAt: Schema.String, mergedAt: Schema.optional(Schema.NullOr(Schema.String)), reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)), + latestReviews: Schema.optional(Schema.NullOr(Schema.Array(RawLatestReviewSchema))), labels: Schema.optional(Schema.Array(RawLabelSchema)), /** * Every check of the head commit, which is the only rollup `gh pr list --json` can give: there @@ -143,6 +150,9 @@ const RawSearchItemSchema = Schema.Struct({ isDraft: Schema.optional(Schema.Boolean), mergeable: Schema.optional(Schema.NullOr(Schema.String)), reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), + latestReviews: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLatestReviewSchema)) })), + ), createdAt: Schema.String, updatedAt: Schema.String, mergedAt: Schema.optional(Schema.NullOr(Schema.String)), @@ -514,13 +524,7 @@ const RawReviewThreadsSchema = Schema.Struct({ ), ), latestReviews: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.Array( - Schema.Struct({ author: Schema.optional(Schema.NullOr(RawActorSchema)) }), - ), - }), - ), + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawLatestReviewSchema) })), ), reviewDismissals: Schema.optional( Schema.NullOr( @@ -698,7 +702,7 @@ export function decodeActorAvatarsJson( } export const PULL_REQUEST_LIST_JSON_FIELDS = - "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup"; + "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,latestReviews,labels,statusCheckRollup"; export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`; @@ -817,6 +821,7 @@ export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = fals isDraft mergeable reviewDecision + latestReviews(first: 20) { nodes { state author { login } } } createdAt updatedAt mergedAt @@ -891,7 +896,7 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin } } latestReviews(first: 50) { - nodes { author { __typename login avatarUrl } } + nodes { state author { __typename login avatarUrl } } } reviewDismissals: timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}) { pageInfo { hasNextPage endCursor } @@ -1325,6 +1330,35 @@ function toMergeMethod(value: string | null | undefined): PullRequestMergeMethod } } +/** + * GitHub's own `reviewDecision` counts only reviews that satisfy the branch rules, so an + * approval from an app (a review bot) or from anyone without the required permission leaves it + * empty. The reviewers still said something, and a row should show it: when GitHub reports no + * verdict, the latest review per reviewer decides, changes requested outranking approval. + */ +function toReviewDecisionWithReviews( + value: string | null | undefined, + // `gh pr list` hands the reviews as an array; the GraphQL reads hand a connection. + latestReviews: + | ReadonlyArray> + | { readonly nodes: ReadonlyArray> } + | null + | undefined, +): PullRequestReviewDecision | null { + const summarized = toReviewDecision(value); + if (summarized === "approved" || summarized === "changes-requested") return summarized; + const reviews = + latestReviews === null || latestReviews === undefined + ? [] + : "nodes" in latestReviews + ? latestReviews.nodes + : latestReviews; + const states = new Set(reviews.map((review) => review.state?.trim().toUpperCase() ?? "")); + if (states.has("CHANGES_REQUESTED")) return "changes-requested"; + if (states.has("APPROVED")) return "approved"; + return summarized; +} + function toReviewDecision(value: string | null | undefined): PullRequestReviewDecision | null { switch (value?.trim().toUpperCase()) { case "APPROVED": @@ -1447,8 +1481,9 @@ function toCheckEntries( * GitHub's own indicator reads: a run that has already gone red will not go green by finishing. * * Null rather than "passing" for a head commit with no checks at all, so a repository that runs - * none shows nothing instead of a green tick it never earned. Checks whose verdict is neither a - * pass, a failure nor a wait — skipped, cancelled, neutral — count towards neither. + * none shows nothing instead of a green tick it never earned. A cancelled run is a failure, as + * GitHub's own rollup and the client's detail rollup both read it; skipped and neutral count + * towards neither, so the row and the detail header never disagree about one head commit. * * Counted off the deduped checks rather than the raw rollup, so the word and the list under it * cannot disagree: the run a re-run replaced is not a verdict twice. A row with no name at all is @@ -1463,7 +1498,7 @@ function rollupChecksState( ...(raw ?? []).filter(isNamelessCheck).map((check) => toCheckStatus(check)), ]; if (statuses.length === 0) return null; - if (statuses.includes("failure")) return "failing"; + if (statuses.includes("failure") || statuses.includes("cancelled")) return "failing"; if (statuses.includes("pending") || statuses.includes("action-required")) return "pending"; return statuses.includes("success") ? "passing" : null; } @@ -1557,7 +1592,7 @@ function toListItem(raw: Schema.Schema.Type): GitHubPu state: toState(raw), isDraft: raw.isDraft ?? false, mergeability: toMergeability(raw.mergeable), - reviewDecision: toReviewDecision(raw.reviewDecision), + reviewDecision: toReviewDecisionWithReviews(raw.reviewDecision, raw.latestReviews), additions: raw.additions ?? 0, deletions: raw.deletions ?? 0, createdAt: raw.createdAt, @@ -1680,6 +1715,9 @@ export function decodePullRequestSearchJson( items.push({ ...toListItem({ ...node, + latestReviews: (node.latestReviews?.nodes ?? []).flatMap((review) => + review === null ? [] : [review], + ), reviewRequests: (node.reviewRequests?.nodes ?? []).flatMap((request) => { const login = trimmed(request?.requestedReviewer?.login); return login === null ? [] : [{ login }]; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2618020d388d..5d46c866a165 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -218,7 +218,7 @@ import { transferBudgetViolations, } from "../integration/TransferBudgetReport.integration.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; -import { otlpSerializationLayer } from "@t3tools/shared/observability"; +import { DEFAULT_SIGNAL_EXPORT, otlpSerializationLayer } from "@t3tools/shared/observability"; const defaultProjectId = ProjectId.make("project-default"); const defaultThreadId = ThreadId.make("thread-default"); @@ -578,10 +578,10 @@ const buildAppUnderTest = (options?: { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", mode: "desktop", port: 0, host: "127.0.0.1", @@ -1076,7 +1076,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.browserTraceCollector, }), ), - Layer.provide(otlpSerializationLayer(config.otlpProtocol)), + Layer.provide(otlpSerializationLayer(config.otlpTracesExport.protocol)), Layer.provide( Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({ publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }), @@ -5299,7 +5299,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ config: { otlpTracesUrl: collector.url, - otlpProtocol: "http/protobuf", + otlpTracesExport: { ...DEFAULT_SIGNAL_EXPORT, protocol: "http/protobuf" }, }, layers: { browserTraceCollector: { diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 59ca908cb4ad..cbb5056ed314 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -8,6 +8,8 @@ import * as Tracer from "effect/Tracer"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; + import * as ServerConfig from "./config.ts"; import { ServerLoggerLive } from "./serverLogger.ts"; @@ -51,10 +53,10 @@ const configLayer = (overrides: Partial) = otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", cwd: baseDir, baseDir, ...derivedPaths, @@ -155,12 +157,15 @@ describe("ServerLoggerLive", () => { }), ); - it.effect("sends the headers and wire format the rest of OTLP export already uses", () => + it.effect("sends the headers and wire format the log signal asked for", () => Effect.gen(function* () { const requests = yield* logThrough({ otlpLogsUrl: "https://collector.example.com/v1/logs", - otlpProtocol: "http/protobuf", - otlpHeaders: { "x-scope": "logs" }, + otlpLogsExport: { + ...DEFAULT_SIGNAL_EXPORT, + protocol: "http/protobuf", + headers: { "x-scope": "logs" }, + }, }); assert.lengthOf(requests, 1); diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index 389a9535efab..6d19907c20ca 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -12,13 +12,14 @@ export const ServerLoggerLive = Effect.gen(function* () { const config = yield* ServerConfig; const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel); + const logs = config.otlpLogsExport; const otlpLogger = config.otlpLogsUrl === undefined ? undefined : OtlpLogger.make({ url: config.otlpLogsUrl, - exportInterval: `${config.otlpExportIntervalMs} millis`, - headers: config.otlpHeaders, + exportInterval: `${logs.exportIntervalMs} millis`, + headers: logs.headers, resource: otlpResource(config), }); @@ -41,7 +42,7 @@ export const ServerLoggerLive = Effect.gen(function* () { { mergeWithExisting: false }, ).pipe( Layer.provide(OtlpExporter.layerFlusher), - Layer.provide(otlpSerializationLayer(config.otlpProtocol)), + Layer.provide(otlpSerializationLayer(logs.protocol)), ); return Layer.mergeAll(loggerLayer, minimumLogLevelLayer); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 2aaabeed3d7a..f450eef0dc24 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -30,6 +30,7 @@ import { type VcsRemoveWorktreeInput, type VcsStatusInput, type VcsStatusResult, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { makeGitVcsDriverCore, @@ -128,6 +129,10 @@ export interface CreateWorktreeProgress { total: number; }) => Effect.Effect; readonly onSubmodulesStarted?: () => Effect.Effect; + /** Fires when `.gitmodules` exists but the resolved submodule mode is `"none"`. */ + readonly onSubmodulesDisabled?: (input: { + source: "settings" | "t3.json"; + }) => Effect.Effect; readonly onSubmoduleLine?: (line: string) => Effect.Effect; readonly onSubmodulesFinished?: (input: { ok: boolean; @@ -137,6 +142,12 @@ export interface CreateWorktreeProgress { export interface CreateWorktreeOptions { readonly progress?: CreateWorktreeProgress; + /** + * The project-over-environment `worktreeSubmodules` setting. Null (or + * omitted, for callers without settings access) defers to the checkout's + * own t3.json. + */ + readonly submodules?: WorktreeSubmodules | null; } export interface GitCommitProgress { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f798abba74b6..98dec86522de 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -26,6 +26,7 @@ import { GitCommandError, ReviewDiffPreviewInput, type ReviewDiffFileContentsInput, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import { gitCommandDuration } from "../observability/Metrics.ts"; @@ -2353,6 +2354,100 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("resolves the submodule mode from the option, then t3.json", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousAllowedProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; + } + }), + ); + + // inner -> nested, so a recursive init populates nested/NESTED.md and + // a top-level init leaves it empty. + const nestedRepo = yield* makeTmpDir("git-nested-"); + yield* initRepoWithCommit(nestedRepo); + yield* writeTextFile(nestedRepo, "NESTED.md", "# nested\n"); + yield* git(nestedRepo, ["add", "."]); + yield* git(nestedRepo, ["commit", "-m", "nested"]); + const innerRepo = yield* makeTmpDir("git-inner-"); + yield* initRepoWithCommit(innerRepo); + yield* writeTextFile(innerRepo, "INNER.md", "# inner\n"); + yield* git(innerRepo, ["submodule", "add", nestedRepo, "nested"]); + yield* git(innerRepo, ["add", "."]); + yield* git(innerRepo, ["commit", "-m", "inner"]); + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["submodule", "add", innerRepo, "inner"]); + yield* git(cwd, ["commit", "-m", "add submodule"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const worktreesDir = yield* makeTmpDir("git-worktrees-"); + + const createWithMode = Effect.fn(function* ( + fileMode: WorktreeSubmodules, + branch: string, + submodules: WorktreeSubmodules | null = null, + ) { + yield* writeTextFile(cwd, "t3.json", `{ "worktreeSubmodules": "${fileMode}" }`); + yield* git(cwd, ["add", "t3.json"]); + // Consecutive cases may reuse a file mode to test the option alone. + yield* git(cwd, ["commit", "--allow-empty", "-m", `submodules: ${fileMode}`]); + const worktreePath = pathService.join(worktreesDir, branch); + const disabled = yield* Ref.make<"settings" | "t3.json" | false>(false); + yield* driver.createWorktree( + { cwd, path: worktreePath, refName: initialBranch, newRefName: branch }, + { + submodules, + progress: { onSubmodulesDisabled: ({ source }) => Ref.set(disabled, source) }, + }, + ); + return { + disabled: yield* Ref.get(disabled), + inner: yield* fileSystem.exists(pathService.join(worktreePath, "inner", "INNER.md")), + nested: yield* fileSystem.exists( + pathService.join(worktreePath, "inner", "nested", "NESTED.md"), + ), + }; + }); + + assert.deepEqual(yield* createWithMode("recursive", "recursive"), { + disabled: false, + inner: true, + nested: true, + }); + assert.deepEqual(yield* createWithMode("top-level", "top-level"), { + disabled: false, + inner: true, + nested: false, + }); + // A resolved setting outranks the file in both directions. + assert.deepEqual(yield* createWithMode("recursive", "setting-none", "none"), { + disabled: "settings", + inner: false, + nested: false, + }); + assert.deepEqual(yield* createWithMode("none", "setting-wins", "top-level"), { + disabled: false, + inner: true, + nested: false, + }); + assert.deepEqual(yield* createWithMode("none", "none"), { + disabled: "t3.json", + inner: false, + nested: false, + }); + }), + ); + it.effect("reports checkout progress during parallel worktree creation", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 56cff0cb9a58..9f91264ee623 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -20,6 +20,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, + T3_PROJECT_FILE_NAME, type ReviewDiffFileContentsInput, type ReviewDiffPreviewInput, type ReviewDiffFileStat, @@ -30,6 +31,8 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3 import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; +import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { @@ -3092,11 +3095,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // skills, tooling or source in one gets a worktree that is quietly missing // them. Best-effort: the objects are usually already in the parent's // `.git/modules`, but a first-ever clone needs the network, and failing to - // populate a submodule must not roll back the caller's thread. + // populate a submodule must not roll back the caller's thread. Repos with + // hundreds of nested submodules opt out or stop at the top level; the + // caller resolves that from settings, or the checkout's t3.json decides. const hasSubmodules = yield* fileSystem .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); - if (hasSubmodules) { + const submoduleMode = !hasSubmodules + ? { value: "none" as const, source: "environment" as const } + : resolveProjectFileBackedSetting( + "worktreeSubmodules", + options?.submodules ?? null, + options?.submodules != null + ? null + : yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe( + Effect.flatMap((contents) => { + const file = parseT3ProjectFile(contents); + return file === null + ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", { + worktreePath, + }).pipe(Effect.as(null)) + : Effect.succeed(file); + }), + Effect.orElseSucceed(() => null), + ), + ); + if (hasSubmodules && submoduleMode.value === "none" && progress?.onSubmodulesDisabled) { + yield* progress.onSubmodulesDisabled({ + source: submoduleMode.source === "t3.json" ? "t3.json" : "settings", + }); + } + if (submoduleMode.value !== "none") { if (progress?.onSubmodulesStarted) { yield* progress.onSubmodulesStarted(); } @@ -3104,7 +3133,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* runGit( "GitVcsDriver.createWorktree.updateSubmodules", worktreePath, - ["submodule", "update", "--init", "--recursive"], + submoduleMode.value === "recursive" + ? ["submodule", "update", "--init", "--recursive"] + : ["submodule", "update", "--init"], onSubmoduleLine ? { env: { LC_ALL: "C" }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d118bd3d012c..652b1ddf3f52 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -81,6 +81,7 @@ import { type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -146,7 +147,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; +import { requiredScopeForRpcMethod, requiredScopeForDeviceList } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; @@ -1044,6 +1045,35 @@ const makeWsRpcLayer = ( return output; }); + // Project setting > environment setting; null when neither is set so + // the driver reads the freshly created checkout's own t3.json (the + // branch being checked out may declare something the project root does + // not). Settings that fail to load fall through the same way. + const resolveBootstrapWorktreeSubmodules = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly projectId: ProjectId | null; + }) { + const settings = yield* serverSettings.getSettings.pipe(Effect.orElseSucceed(() => null)); + if (!settings) return null; + // A worktree can also be prepared for an existing thread, whose + // project is only known through its shell. + const resolvedProjectId = + input.projectId ?? + (yield* projectionSnapshotQuery.getThreadShellById(input.threadId).pipe( + Effect.map((thread) => Option.getOrNull(thread)?.projectId ?? null), + Effect.orElseSucceed(() => null), + )); + const project = + resolvedProjectId === null + ? null + : yield* projectionSnapshotQuery.getProjectShellById(resolvedProjectId).pipe( + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ); + return resolveProjectSettings(settings, resolvedProjectId, project).settings + .worktreeSubmodules; + }); + const dispatchBootstrapTurnStart = ( command: Extract, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => @@ -1462,6 +1492,10 @@ const makeWsRpcLayer = ( } yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running"); let checkoutTotal: number | null = null; + const submodules = yield* resolveBootstrapWorktreeSubmodules({ + threadId, + projectId: targetProjectId ?? null, + }); const worktree = yield* gitWorkflow.createWorktree( { cwd: prepareWorktree.projectCwd, @@ -1471,6 +1505,7 @@ const makeWsRpcLayer = ( path: null, }, { + submodules, progress: { // Git has registered the directory at this point, so a // cancel during the submodule step can still remove it. @@ -1500,6 +1535,13 @@ const makeWsRpcLayer = ( worktreeSetupTracker.stageStatus(threadId, "submodules", "running"), ), ), + onSubmodulesDisabled: ({ source }) => + worktreeSetupTracker.stageStatus( + threadId, + "submodules", + "skipped", + `disabled in ${source}`, + ), onSubmoduleLine: (line) => { const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; return submodulePath === undefined @@ -3453,10 +3495,23 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), { "rpc.aggregate": "device", }), - [WS_METHODS.deviceList]: (_input) => - observeRpcEffect(WS_METHODS.deviceList, deviceService.list, { - "rpc.aggregate": "device", - }), + [WS_METHODS.deviceList]: (input) => + observeRpcEffect( + WS_METHODS.deviceList, + input.inspectOnly && !input.updateTool + ? deviceService.inspect + : authorizeEffect( + requiredScopeForDeviceList(input), + input.updateTool + ? deviceService.updateTool(input.updateTool) + : input.retryHostId + ? deviceService.retryHost(input.retryHostId) + : deviceService.list, + ), + { + "rpc.aggregate": "device", + }, + ), [WS_METHODS.deviceOpen]: (input) => observeRpcEffect(WS_METHODS.deviceOpen, deviceService.open(input), { "rpc.aggregate": "device", diff --git a/apps/web/package.json b/apps/web/package.json index e6980cf778ea..ec7334e1fd86 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", "@tiptap/core": "^3.31.3", + "@tiptap/extension-code": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", "@tiptap/extension-task-list": "^3.31.3", "@tiptap/pm": "^3.31.3", diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 42d5bcfb35b8..6c303afb27e9 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -156,9 +156,18 @@ export function HostedBrowserWebview(props: { } }, recovery.delayMs); }; + // A click inside the guest only reaches this document as a webview focus + // event, so open menus and popovers never see the outside press that + // would dismiss them. Replay it as a pointerdown on the webview itself. + const dismissHostPopups = () => { + webview.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }), + ); + }; webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); webview.addEventListener("render-process-gone", recoverGuest); + webview.addEventListener("focus", dismissHostPopups); register(); return () => { disposed = true; @@ -166,6 +175,7 @@ export function HostedBrowserWebview(props: { webview.removeEventListener("did-attach", register); webview.removeEventListener("dom-ready", register); webview.removeEventListener("render-process-gone", recoverGuest); + webview.removeEventListener("focus", dismissHostPopups); }; }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 5ea10c2b05d5..0bbe4bd7bf18 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -8,6 +8,8 @@ import { previewBridge } from "~/components/preview/previewBridge"; import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings"; import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { createRecordingCompositor } from "./recordingCompositor"; + import { acquireBrowserSurfaceActivity } from "./browserSurfaceStore"; export class BrowserRecordingUnavailableError extends Schema.TaggedError()( @@ -123,6 +125,7 @@ interface ActiveRecording { releaseSurfaceActivity: (() => void) | null; stream: MediaStream | null; recorder: MediaRecorder | null; + compositor: Awaited>; savedBlob?: Blob; uploadPromise?: Promise; lifecycle: BrowserRecordingLifecycle; @@ -381,6 +384,8 @@ const captureTabMediaStreamWithTimeout = async ( }; const clearActiveRecording = (recording: ActiveRecording): void => { + recording.compositor?.dispose(); + recording.compositor = null; recording.releaseSurfaceActivity?.(); recording.releaseSurfaceActivity = null; if (activeRecordings.get(recording.tabId) !== recording) return; @@ -525,6 +530,7 @@ export async function startBrowserRecording( releaseSurfaceActivity, stream: null, recorder: null, + compositor: null, lifecycle: startingLifecycle, }; activeRecordings.set(tabId, recording); @@ -534,7 +540,8 @@ export async function startBrowserRecording( clearActiveRecording(recording); throw cause; }); - const frameRate = getClientSettings().browserRecordingFrameRate; + const settings = getClientSettings(); + const frameRate = settings.browserRecordingFrameRate; await waitForBrowserRecordingPaint(); const throwIfStartupCancelled = async (): Promise => { // Once a grant starts, a stop lets startup finish so the caller receives an artifact. @@ -614,7 +621,19 @@ export async function startBrowserRecording( let recorder: MediaRecorder; try { - recorder = createMediaRecorder(stream); + recording.compositor = await createRecordingCompositor( + stream, + { + showKeyPresses: settings.browserRecordingShowKeyPresses, + showMousePresses: settings.browserRecordingShowMousePresses, + frameRate, + }, + (listener) => + bridge.recording.onInput((event) => { + if (event.tabId === tabId) listener(event.input); + }), + ); + recorder = createMediaRecorder(recording.compositor?.stream ?? stream); recording.recorder = recorder; recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) chunks.push(event.data); @@ -694,6 +713,8 @@ const finalizeBrowserRecording = async ( cause, }); } + recording.compositor?.dispose(); + recording.compositor = null; // Encoding has flushed; release native capture before materializing and saving the file. stopMediaStream(recording.stream); recording.stream = null; diff --git a/apps/web/src/browser/recordingCompositor.test.ts b/apps/web/src/browser/recordingCompositor.test.ts new file mode 100644 index 000000000000..d10e69d24663 --- /dev/null +++ b/apps/web/src/browser/recordingCompositor.test.ts @@ -0,0 +1,179 @@ +import type { DesktopPreviewRecordingInput } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createRecordingCompositor, RecordingDecorations } from "./recordingCompositor"; + +const primaryColor = "oklch(0.65 0.2 310)"; +vi.mock("./annotationTheme", () => ({ + readPreviewAnnotationTheme: () => ({ primary: "oklch(0.65 0.2 310)" }), +})); + +const options = { showKeyPresses: true, showMousePresses: true, frameRate: 30 }; +const pointer = ( + phase: "move" | "down" | "up" | "click", + x = 100, +): DesktopPreviewRecordingInput => ({ + type: "pointer", + phase, + x, + y: 80, + width: 800, + height: 600, +}); +const context = () => ({ + save: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + fill: vi.fn(), + stroke: vi.fn(), + ellipse: vi.fn(), + roundRect: vi.fn(), + fillText: vi.fn(), + drawImage: vi.fn(), + measureText: () => ({ width: 40 }), + globalAlpha: 1, + strokeStyle: "", + fillStyle: "", +}); + +describe("recording decorations", () => { + it("keeps rings aligned through dragging and stops following the cursor after release", () => { + const decorations = new RecordingDecorations(options, primaryColor); + const ctx = context(); + decorations.apply(pointer("down"), 0); + decorations.apply(pointer("move", 120), 10); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 10); + expect(ctx.ellipse.mock.calls[0]?.slice(0, 4)).toEqual([240, 160, 40, 40]); + expect(ctx.strokeStyle).toBe(primaryColor); + expect(ctx.fillStyle).toBe(primaryColor); + expect(decorations.nextRedraw(10)).toBeNull(); + decorations.apply(pointer("up", 130), 20); + decorations.apply(pointer("move", 300), 30); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 320); + expect(ctx.ellipse.mock.calls[1]?.slice(0, 4)).toEqual([260, 160, 50, 50]); + ctx.ellipse.mockClear(); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 620); + expect(ctx.ellipse).not.toHaveBeenCalled(); + expect(decorations.nextRedraw(620)).toBeNull(); + }); + + it("pulses agent clicks and clears decorations on blur or navigation", () => { + const decorations = new RecordingDecorations(options, primaryColor); + const ctx = context(); + decorations.apply(pointer("click"), 0); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 300); + expect(ctx.ellipse).toHaveBeenCalledOnce(); + decorations.apply({ type: "clear" }, 301); + ctx.ellipse.mockClear(); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 302); + expect(ctx.ellipse).not.toHaveBeenCalled(); + expect(decorations.nextRedraw(302)).toBeNull(); + }); + + it("holds shortcut badges until release, then expires them even on a static page", () => { + const decorations = new RecordingDecorations(options, primaryColor); + const ctx = context(); + const key = { type: "key" as const, label: "⌘C", held: true, width: 800 }; + decorations.apply(key, 0); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 5000); + expect(ctx.fillText.mock.calls[0]?.slice(0, 3)).toEqual(["⌘C", 800, 1098]); + decorations.apply({ ...key, held: false }, 5000); + expect(decorations.nextRedraw(5500)).toBe(400); + ctx.fillText.mockClear(); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 5900); + expect(ctx.fillText).not.toHaveBeenCalled(); + }); + + it("removes the previous key badge on password focus", () => { + const decorations = new RecordingDecorations(options, primaryColor); + const ctx = context(); + decorations.apply({ type: "key", label: "A", held: true, width: 800 }, 0); + decorations.apply({ type: "key", label: null, held: true, width: 800 }, 1); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 2); + expect(ctx.fillText).not.toHaveBeenCalled(); + }); + + it("honors independent opt-in flags", () => { + const decorations = new RecordingDecorations( + { ...options, showMousePresses: false }, + primaryColor, + ); + const ctx = context(); + decorations.apply(pointer("down"), 0); + decorations.apply({ type: "key", label: "⌘C", held: true, width: 800 }, 0); + decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 1); + expect(ctx.ellipse).not.toHaveBeenCalled(); + expect(ctx.fillText).toHaveBeenCalledOnce(); + }); +}); + +describe("detached recording compositor", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("keeps native capture when both decorations are off", async () => { + vi.stubGlobal("document", { + createElement: () => { + throw new Error("must not allocate"); + }, + }); + expect( + await createRecordingCompositor( + {} as MediaStream, + { + ...options, + showKeyPresses: false, + showMousePresses: false, + }, + () => { + throw new Error("must not subscribe"); + }, + ), + ).toBeNull(); + }); + + it.each([false, true])( + "releases the detached output on disposal or playback failure (%s)", + async (failPlayback) => { + const ctx = context(); + const stop = vi.fn(); + const unsubscribe = vi.fn(); + const cancelFrame = vi.fn(); + const source = { + getVideoTracks: () => [{ getSettings: () => ({ width: 800, height: 600 }) }], + } as unknown as MediaStream; + const stream = { getTracks: () => [{ stop }] } as unknown as MediaStream; + const canvas = { width: 0, height: 0, getContext: () => ctx, captureStream: () => stream }; + const video = { + muted: false, + playsInline: false, + srcObject: null as MediaStream | null, + readyState: 2, + videoWidth: 800, + videoHeight: 600, + pause: vi.fn(), + play: async () => { + if (failPlayback) throw new Error("play failed"); + }, + requestVideoFrameCallback: () => 1, + cancelVideoFrameCallback: cancelFrame, + }; + vi.stubGlobal("document", { + createElement: (tag: string) => (tag === "canvas" ? canvas : video), + }); + vi.stubGlobal("window", { clearTimeout: vi.fn(), setTimeout: vi.fn() }); + const compositor = createRecordingCompositor(source, options, () => unsubscribe); + if (failPlayback) await expect(compositor).rejects.toThrow("play failed"); + else { + const result = await compositor; + expect(result?.stream).toBe(stream); + expect(ctx.drawImage).toHaveBeenCalledOnce(); + result?.dispose(); + result?.dispose(); + } + expect(stop).toHaveBeenCalledOnce(); + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(cancelFrame).toHaveBeenCalledWith(1); + expect(video.srcObject).toBeNull(); + }, + ); +}); diff --git a/apps/web/src/browser/recordingCompositor.ts b/apps/web/src/browser/recordingCompositor.ts new file mode 100644 index 000000000000..352154bd9aef --- /dev/null +++ b/apps/web/src/browser/recordingCompositor.ts @@ -0,0 +1,190 @@ +import type { DesktopPreviewRecordingInput } from "@t3tools/contracts"; + +import { readPreviewAnnotationTheme } from "./annotationTheme"; + +interface RecordingDecorationOptions { + readonly showKeyPresses: boolean; + readonly showMousePresses: boolean; + readonly frameRate: number; +} + +/** Decorates a detached canvas; no recording UI is inserted into the preview page. */ +export async function createRecordingCompositor( + source: MediaStream, + options: RecordingDecorationOptions, + subscribe: (listener: (input: DesktopPreviewRecordingInput) => void) => () => void, +) { + if (!options.showKeyPresses && !options.showMousePresses) return null; + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d", { alpha: false }); + if (!context) throw new Error("Recording canvas is unavailable."); + const video = document.createElement("video"); + video.muted = true; + video.playsInline = true; + video.srcObject = source; + const settings = source.getVideoTracks()[0]?.getSettings(); + canvas.width = settings?.width ?? 1920; + canvas.height = settings?.height ?? 1080; + const decorations = new RecordingDecorations(options, readPreviewAnnotationTheme().primary); + let disposed = false; + let frameId: number | undefined; + let timer: number | undefined; + const draw = () => { + if (disposed || video.readyState < 2) return; + const width = video.videoWidth || canvas.width; + const height = video.videoHeight || canvas.height; + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; + context.drawImage(video, 0, 0, width, height); + const now = performance.now(); + decorations.draw(context, width, height, now); + window.clearTimeout(timer); + const next = decorations.nextRedraw(now); + if (next !== null) timer = window.setTimeout(draw, next); + }; + const frame = () => { + if (disposed) return; + draw(); + frameId = video.requestVideoFrameCallback(frame); + }; + const output = canvas.captureStream(options.frameRate); + let unsubscribe: (() => void) | undefined; + const dispose = () => { + if (disposed) return; + disposed = true; + unsubscribe?.(); + window.clearTimeout(timer); + if (frameId !== undefined) video.cancelVideoFrameCallback(frameId); + video.pause(); + video.srcObject = null; + for (const track of output.getTracks()) track.stop(); + }; + try { + unsubscribe = subscribe((input) => { + decorations.apply(input, performance.now()); + draw(); + }); + frameId = video.requestVideoFrameCallback(frame); + await video.play(); + draw(); + return { stream: output, dispose }; + } catch (error) { + dispose(); + throw error; + } +} + +/** Keeps input timing and coordinates independent of native video frame delivery. */ +export class RecordingDecorations { + private ring: { + x: number; + y: number; + width: number; + height: number; + held: boolean; + releasedAt: number | null; + } | null = null; + private key: { label: string; width: number; expiresAt: number | null } | null = null; + + constructor( + private readonly options: RecordingDecorationOptions, + private readonly primaryColor: string, + ) {} + + apply(input: DesktopPreviewRecordingInput, now: number) { + if (input.type === "clear") { + this.ring = null; + this.key = null; + } else if (input.type === "key" && this.options.showKeyPresses) { + this.key = input.label + ? { label: input.label, width: input.width, expiresAt: input.held ? null : now + 900 } + : null; + } else if (input.type === "pointer" && this.options.showMousePresses) { + if (input.phase === "down" || input.phase === "click") { + this.ring = { + x: input.x, + y: input.y, + width: input.width, + height: input.height, + held: input.phase === "down", + releasedAt: input.phase === "click" ? now : null, + }; + } else if (this.ring?.held) { + this.ring = { + ...this.ring, + x: input.x, + y: input.y, + width: input.width, + height: input.height, + held: input.phase !== "up", + releasedAt: input.phase === "up" ? now : null, + }; + } + } + } + + nextRedraw(now: number): number | null { + if ( + this.ring?.releasedAt !== null && + this.ring?.releasedAt !== undefined && + now < this.ring.releasedAt + 600 + ) { + return 1000 / this.options.frameRate; + } + return this.key?.expiresAt !== null && + this.key?.expiresAt !== undefined && + now < this.key.expiresAt + ? this.key.expiresAt - now + : null; + } + + draw(context: CanvasRenderingContext2D, width: number, height: number, now: number) { + // Guest coordinates are CSS pixels; native frames include zoom and display scale. + const scale = width / (this.key?.width ?? this.ring?.width ?? 1280); + const ring = this.ring; + if (ring && (ring.held || (ring.releasedAt !== null && now < ring.releasedAt + 600))) { + const progress = ring.releasedAt === null ? 0 : Math.min(1, (now - ring.releasedAt) / 600); + context.save(); + const opacity = 0.9 * (1 - progress); + context.strokeStyle = this.primaryColor; + context.fillStyle = this.primaryColor; + context.lineWidth = 2 * scale; + context.beginPath(); + context.ellipse( + (ring.x * width) / ring.width, + (ring.y * height) / ring.height, + ((20 * width) / ring.width) * (1 + progress * 0.5), + ((20 * height) / ring.height) * (1 + progress * 0.5), + 0, + 0, + Math.PI * 2, + ); + context.globalAlpha = opacity * 0.15; + context.fill(); + context.globalAlpha = opacity; + context.stroke(); + context.restore(); + } + const key = this.key; + if (key && (key.expiresAt === null || now < key.expiresAt)) { + context.save(); + context.font = `500 ${26 * scale}px system-ui, sans-serif`; + const badgeWidth = Math.min( + width - 32 * scale, + context.measureText(key.label).width + 36 * scale, + ); + const badgeHeight = 54 * scale; + const left = (width - badgeWidth) / 2; + const top = height - 24 * scale - badgeHeight; + context.fillStyle = "rgba(32,32,34,.86)"; + context.beginPath(); + context.roundRect(left, top, badgeWidth, badgeHeight, 14 * scale); + context.fill(); + context.fillStyle = "white"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(key.label, width / 2, top + badgeHeight / 2, badgeWidth - 24 * scale); + context.restore(); + } + } +} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 52b45ab8405c..14d871c41804 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -244,7 +244,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { side="left" collapsible="offcanvas" data-app-sidebar="" - className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground" + className="border-r border-sidebar-border" resizable={{ maxWidth: sidebarMaximumWidth, minWidth: THREAD_SIDEBAR_MIN_WIDTH, diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 0577f5e8dd1f..5b046ed6a686 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,4 +1,10 @@ -import type { EnvironmentId, EnvironmentMachineKind, VcsRef, ProjectId } from "@t3tools/contracts"; +import type { + EnvironmentId, + EnvironmentMachineKind, + VcsRef, + ProjectId, + WorktreeSubmodules, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { toSortableTimestamp } from "../lib/threadSort"; export { @@ -86,6 +92,12 @@ export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } +export const WORKTREE_SUBMODULES_LABELS: Record = { + recursive: "Recursive", + "top-level": "Top level only", + none: "Skip", +}; + export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string { return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 3330f40a090f..ffc89c33e794 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -54,6 +54,7 @@ import { } from "./ui/menu"; import { Separator } from "./ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { MiddleTruncate } from "./ui/middle-truncate"; import { ComposerSurface } from "./chat/ComposerSurface"; import { useComposerMenuProps } from "./chat/composerEventScope"; import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; @@ -233,6 +234,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ { if (autoEnvironmentLabel) onAutoEnvironment?.(); }} @@ -250,6 +252,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ key={env.environmentId} disabled={envLocked} value={env.environmentId} + closeOnClick > @@ -274,29 +277,27 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ onEnvModeChange(value as EnvMode); }} > - + {activeWorktreePath ? ( ) : ( )} - - {resolveCurrentWorkspaceLabel(activeWorktreePath)} - + - + {resolveEnvModeLabel("worktree")} {previousWorktreeLabel ? ( - + - {previousWorktreeLabel} + ) : null} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index d958f2f76625..ed8f1cd1f026 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -73,6 +73,7 @@ import { } from "./ui/combobox"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { MiddleTruncate } from "./ui/middle-truncate"; export interface BranchToolbarBranchSelectorHandle { open: () => void; @@ -545,6 +546,7 @@ export function BranchToolbarBranchSelector({ setIsBranchMenuOpen(open); if (!open) { setBranchQuery(""); + highlightedBranchValueRef.current = null; } }, []); @@ -594,6 +596,9 @@ export function BranchToolbarBranchSelector({ }, [fetchNextBranchPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); const branchListRef = useRef(null); + // Tracks the highlighted picker value so Enter can activate it even when the + // virtualized row is not mounted (Base UI Enter clicks the mounted element). + const highlightedBranchValueRef = useRef(null); const updateBranchListScrollFades = useCallback(() => { const scrollElement = branchListRef.current?.getScrollableNode?.(); if (!(scrollElement instanceof HTMLElement)) { @@ -676,6 +681,20 @@ export function BranchToolbarBranchSelector({ const prUrl = currentLinkedPr?.url ?? displayedPr?.url; const openPrLink = useOpenPrLink(threadRef); + function selectPickerItem(itemValue: string) { + highlightedBranchValueRef.current = null; + if (itemValue === checkoutPullRequestItemValue && prReference && onCheckoutPullRequestRequest) { + handleOpenChange(false); + onComposerFocusRequest?.(); + onCheckoutPullRequestRequest(prReference); + } else if (itemValue === createBranchItemValue) { + createRef(trimmedBranchQuery); + } else { + const refName = branchByName.get(itemValue); + if (refName) selectBranch(refName); + } + } + function renderPickerItem(itemValue: string, index: number) { if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { return ( @@ -685,15 +704,7 @@ export function BranchToolbarBranchSelector({ index={index} value={itemValue} className="pe-2" - onClick={() => { - if (!prReference || !onCheckoutPullRequestRequest) { - return; - } - setIsBranchMenuOpen(false); - setBranchQuery(""); - onComposerFocusRequest?.(); - onCheckoutPullRequestRequest(prReference); - }} + onClick={() => selectPickerItem(itemValue)} >
@@ -715,7 +726,7 @@ export function BranchToolbarBranchSelector({ index={index} value={itemValue} className="pe-1.5" - onClick={() => createRef(trimmedBranchQuery)} + onClick={() => selectPickerItem(itemValue)} > Create new ref "{newRefName}" @@ -743,11 +754,11 @@ export function BranchToolbarBranchSelector({ index={index} value={itemValue} className="pe-1.5" - onClick={() => selectBranch(refName)} + onClick={() => selectPickerItem(itemValue)} onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} >
- {itemValue} + {badge && {badge}}
@@ -760,7 +771,8 @@ export function BranchToolbarBranchSelector({ filteredItems={filteredBranchPickerItems} autoHighlight virtualized - onItemHighlighted={(_value, eventDetails) => { + onItemHighlighted={(value, eventDetails) => { + highlightedBranchValueRef.current = typeof value === "string" ? value : null; if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { return; } @@ -807,12 +819,11 @@ export function BranchToolbarBranchSelector({ data-composer-label className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0" > - - {triggerLabel} - + className="flex w-full max-w-[240px] transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none" + /> @@ -828,11 +839,29 @@ export function BranchToolbarBranchSelector({ placeholder="Search refs..." value={branchQuery} onChange={(event) => setBranchQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing || event.keyCode === 229) { + return; + } + const highlightedValue = highlightedBranchValueRef.current; + if ( + highlightedValue === null || + !filteredBranchPickerItems.includes(highlightedValue) + ) { + return; + } + ( + event as typeof event & { preventBaseUIHandler?: () => void } + ).preventBaseUIHandler?.(); + event.preventDefault(); + event.stopPropagation(); + selectPickerItem(highlightedValue); + }} />
No refs found.
- + ref={branchListRef} data={filteredBranchPickerItems} @@ -876,7 +905,7 @@ export function BranchToolbarBranchSelector({ className="flex cursor-pointer items-center justify-between gap-3 border-t border-border/60 px-3 py-2 text-xs" > - ({ vi.mock("~/lib/openPullRequestLink", () => ({ findProjectOnChangeRequestHost: () => undefined, parseChangeRequestUrl: () => null, + resolvePullRequestPreviewTarget: () => null, useOpenChangeRequestLink: () => vi.fn(), })); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index abebe4841cbd..3b398dd5100e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -53,7 +53,6 @@ import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-li import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { Children, @@ -178,9 +177,9 @@ import { WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; import { - findProjectForChangeRequest, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, + resolvePullRequestPreviewTarget, useOpenChangeRequestLink, } from "~/lib/openPullRequestLink"; import { useOpenLink } from "../browser/useOpenLink"; @@ -863,7 +862,10 @@ function MarkdownDetails({ {summary} -
+
{content}
@@ -2886,32 +2888,14 @@ const CHAT_MARKDOWN_COMPONENTS = { const confirmBeforeOpen = pullRequestAutolink === "reference"; const pullRequestCandidateUrl = confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href; - const pullRequestCandidate = pullRequestCandidateUrl - ? parseChangeRequestUrl(pullRequestCandidateUrl) + const pullRequestPreviewTarget = pullRequestCandidateUrl + ? resolvePullRequestPreviewTarget({ + environmentId, + projects, + pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true, + url: pullRequestCandidateUrl, + }) : null; - const pullRequestProject = - environmentId !== null && - serverConfig?.environment.capabilities.pullRequests === true && - pullRequestCandidate !== null - ? findProjectForChangeRequest( - projects.filter((project) => project.environmentId === environmentId), - pullRequestCandidate, - ) - : undefined; - const pullRequestPreviewTarget = - environmentId === null || pullRequestProject === undefined || pullRequestCandidate === null - ? null - : { - environmentId, - input: { - projectId: pullRequestProject.id, - host: pullRequestCandidate.authority ?? pullRequestCandidate.host, - repository: - sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ?? - pullRequestCandidate.repository, - number: pullRequestCandidate.number, - }, - }; const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); @@ -3332,7 +3316,7 @@ function ChatMarkdown({
({ vi.mock("~/lib/openPullRequestLink", () => ({ findProjectOnChangeRequestHost: () => undefined, parseChangeRequestUrl: () => null, + resolvePullRequestPreviewTarget: () => null, useOpenChangeRequestLink: () => vi.fn(), })); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 667eb0e11f6c..981f6f2c04af 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -229,6 +229,7 @@ import { } from "@t3tools/client-runtime/state/subagentRuntime"; import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { isEditableFocused } from "../lib/editableFocus"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, @@ -6647,11 +6648,12 @@ export default function ChatView(props: ChatViewProps) { }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); const getShortcutContext = useCallback( - () => ({ + (eventTarget: EventTarget | null = document.activeElement) => ({ terminalFocus: getTerminalFocusOwner() !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), previewFocus: isPreviewFocused(), previewOpen: previewPanelOpen, + editableFocus: isEditableFocused(eventTarget), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, isWeb: !isElectron, isDesktop: isElectron, @@ -6679,7 +6681,7 @@ export default function ChatView(props: ChatViewProps) { if (event.defaultPrevented && terminalFocusOwner === null) { return; } - const shortcutContext = getShortcutContext(); + const shortcutContext = getShortcutContext(event.target); if ( !shortcutContext.terminalFocus && diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 5126246dd05c..0e452fd4ac26 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -46,6 +46,7 @@ import { buildDocJson, buildTiptapContent, collapsedToFlat, + ComposerCodeExtension, ComposerTaskItemExtension, flatToCollapsed, flatToMarkdown, @@ -136,7 +137,7 @@ export interface ComposerPromptEditorProps { ) => void; onVisibleSelectionChange?: () => void; onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape", event: KeyboardEvent, isTaskItem?: boolean, ) => boolean; @@ -725,6 +726,19 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ); }, []); + const editorAttributes = useMemo( + () => ({ + class: cn( + "composer-tiptap block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", + className, + ), + "data-testid": "composer-editor", + "data-composer-rich-text": richText ? "true" : "false", + "aria-placeholder": placeholder, + }), + [className, placeholder, richText], + ); + const editor = useEditor( { extensions: [ @@ -741,8 +755,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { dropcursor: false, gapcursor: false, trailingNode: false, + code: false, // Plain mode has no marks: typed markers stay literal characters. - ...(richText ? {} : { bold: false, italic: false, strike: false, code: false }), + ...(richText ? {} : { bold: false, italic: false, strike: false }), }), ComposerMentionExtension, ComposerSkillExtension, @@ -751,6 +766,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ComposerMarkersExtension, ...(richText ? [ + ComposerCodeExtension, TaskList, ComposerTaskItemExtension.extend({ addInputRules() { @@ -787,15 +803,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ), editable: !disabled, editorProps: { - attributes: { - class: cn( - "composer-tiptap block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", - className, - ), - "data-testid": "composer-editor", - "data-composer-rich-text": richText ? "true" : "false", - "aria-placeholder": placeholder, - }, + attributes: editorAttributes, handleKeyDown: (view, event) => { if ( isMacPlatform(navigator.platform) && @@ -904,7 +912,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ? ("ArrowDown" as const) : event.key === "ArrowUp" ? ("ArrowUp" as const) - : null; + : event.key === "Escape" + ? ("Escape" as const) + : null; if (!key) return false; const handled = handler(key, event); if (handled) { @@ -995,6 +1005,17 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { editorHolder.current = editor; }, [editor]); + // Tiptap forwards option changes to the view from a passive effect, so a + // class change here would reach the ProseMirror element one tick after + // React commits. The chat composer measures its resting and expanded + // geometry in layout effects that run first, and it clamps the prompt + // through `className`, so the attributes are pushed to the view here for + // those measurements to see the layout they are about to reserve for. + useLayoutEffect(() => { + if (!editor?.isInitialized) return; + editor.view.setProps({ attributes: editorAttributes }); + }, [editor, editorAttributes]); + const readSnapshot = useCallback(() => { const snapshot = snapshotRef.current; if (!editor) return snapshot; diff --git a/apps/web/src/components/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx index 322423c40a94..3e19606bd4c6 100644 --- a/apps/web/src/components/ConfirmDialogHost.tsx +++ b/apps/web/src/components/ConfirmDialogHost.tsx @@ -75,7 +75,7 @@ export function ConfirmDialogHost() { if (!open) completeConfirmDialogClose(); }} > - + {copy.title} {copy.description ? ( diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index d80ebf0d36fb..453efb1cbe53 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -10,6 +10,7 @@ import { import { Button } from "./ui/button"; import { CalendarIcon } from "lucide-react"; import { Calendar } from "./ui/calendar"; +import { weekStartsOn } from "../timestampFormat"; import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; @@ -149,6 +150,7 @@ function CustomSnoozeDialog() { required selected={date} defaultMonth={date} + {...(weekStartsOn === undefined ? {} : { weekStartsOn })} disabled={{ before: new Date(new Date().setHours(0, 0, 0, 0)) }} onSelect={(selected) => { setDate(selected); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index fd29acbff3f7..21cd54792d11 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -856,7 +856,7 @@ export default function DiffPanel({ /> } > - + {isRefreshingDiff ? "Refreshing diff…" : "Refresh diff"} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 99db055b667b..4eca7867132e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -79,7 +79,16 @@ import { } from "~/components/ui/dialog"; import { Group, GroupSeparator } from "~/components/ui/group"; import { Input } from "~/components/ui/input"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; +import { + Menu, + MenuItem, + MenuItemLabel, + MenuPopup, + MenuSub, + MenuSubTrigger, + MenuSubPopup, + MenuTrigger, +} from "~/components/ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Textarea } from "~/components/ui/textarea"; @@ -108,6 +117,7 @@ import { useOpenLink } from "~/browser/useOpenLink"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; interface GitActionsControlProps { + presentation?: "toolbar" | "menu"; gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; draftId?: DraftId; @@ -374,24 +384,25 @@ function GitActionItemIcon({ function GitQuickActionIcon({ quickAction, SourceControlIcon, + className = "size-3.5", }: { quickAction: GitQuickAction; + className?: string; SourceControlIcon: ReturnType["Icon"]; }) { - const iconClassName = "size-3.5"; - if (quickAction.kind === "open_pr") return ; - if (quickAction.kind === "open_publish") return ; - if (quickAction.kind === "run_pull") return ; + if (quickAction.kind === "open_pr") return ; + if (quickAction.kind === "open_publish") return ; + if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { - if (quickAction.action === "commit") return ; + if (quickAction.action === "commit") return ; if (quickAction.action === "push" || quickAction.action === "commit_push") { - return ; + return ; } - return ; + return ; } - if (quickAction.label === "Commit") return ; - if (quickAction.label === "Push") return ; - return ; + if (quickAction.label === "Commit") return ; + if (quickAction.label === "Push") return ; + return ; } interface PublishRepositoryDialogProps { @@ -840,7 +851,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { aria-live="polite" className="flex items-center gap-2 rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground dark:border-transparent dark:bg-white/[0.035]" > - + Publishing repository to {publishProviderLabel}...
) : null} @@ -927,7 +938,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { + + + ); + })} + {importMenuItems} + + + Add action + + + ); + return ( <> - {primaryScript ? ( + {presentation === "menu" ? ( + <> + {primaryScript && ( + onRunScript(primaryScript)} + > + + Run {primaryScript.name} + + {shortcutLabelForCommand(keybindings, commandForProjectScript(primaryScript.id))} + + + )} + {primaryScript || importableScripts.length > 0 ? ( + + setActionsMenuOpen({ presentation, scripts: open, imports: false }) + } + > + + + Project actions + + + {scriptItems} + + + ) : ( + + + Add project action… + + )} + + ) : primaryScript ? ( setActionsMenuOpen({ scripts: open, imports: false })} + onOpenChange={(open) => + setActionsMenuOpen({ presentation, scripts: open, imports: false }) + } > } > - - {scripts.map((script) => { - const shortcutLabel = shortcutLabelForCommand( - keybindings, - commandForProjectScript(script.id), - ); - return ( - onRunScript(script)} - > - - - {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name} - - - {shortcutLabel && ( - - {shortcutLabel} - - )} - - - - ); - })} - {importMenuItems} - - - Add action - - + {scriptItems} ) : importableScripts.length > 0 ? ( setActionsMenuOpen({ scripts: false, imports: open })} + onOpenChange={(open) => + setActionsMenuOpen({ presentation, scripts: false, imports: open }) + } > }> diff --git a/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx b/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx index 56aaa9e96ff0..27df0460f500 100644 --- a/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx +++ b/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx @@ -118,7 +118,7 @@ function EnvironmentUpdateRow({ let trailing: ReactNode; switch (status.kind) { case "loading": - trailing = ; + trailing = ; break; case "success": trailing = ); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index da293c38ddc9..13606e75c870 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -17,7 +17,7 @@ import { } from "@t3tools/shared/model"; import { memo, useCallback } from "react"; import type { VariantProps } from "class-variance-authority"; -import { ZapIcon } from "lucide-react"; +import { GaugeIcon, ZapIcon } from "lucide-react"; import { buttonVariants } from "../ui/button"; import { Menu, @@ -32,6 +32,7 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { ComposerControl, ComposerControlChevron, @@ -589,6 +590,7 @@ export const TraitsPicker = memo(function TraitsPicker({ primarySelectDescriptorId: primarySelectDescriptor?.id ?? null, ultrathinkPromptControlled, }); + const accessibleLabel = showFastModeIcon ? `${triggerLabel}, Fast mode on` : triggerLabel; const fastModeIcon = showFastModeIcon ? ( <> - - } - > - {isCodexStyle ? ( - // The label truncates itself; clipping the wrapper too would cut off - // the chevron, whose negative end margin overhangs the wrapper edge. - - {fastModeIcon} - {triggerLabel} - - - ) : ( - <> - {fastModeIcon} - {triggerLabel} - - - )} - + + + } + /> + } + > + {isCodexStyle ? ( + // The label truncates itself; clipping the wrapper too would cut off + // the chevron, whose negative end margin overhangs the wrapper edge. + + {fastModeIcon ?? ( + + + + )} + + {triggerLabel} + + + + ) : ( + <> + {fastModeIcon ?? ( + + + + )} + {triggerLabel} + + + )} + + {accessibleLabel} + ; case "running": - return ; + return ; case "failed": return ; case "warning": @@ -260,19 +261,25 @@ function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { {snapshot.branch ? ( <>
Branch
-
{snapshot.branch}
+
+ +
) : null} {snapshot.baseRef ? ( <>
Base
-
{snapshot.baseRef}
+
+ +
) : null} {snapshot.worktreePath ? ( <>
Path
-
{snapshot.worktreePath}
+
+ +
) : null} {snapshot.setupScript ? ( diff --git a/apps/web/src/components/chat/assistantCitationCommentDismissal.test.ts b/apps/web/src/components/chat/assistantCitationCommentDismissal.test.ts new file mode 100644 index 000000000000..1f21677a5a82 --- /dev/null +++ b/apps/web/src/components/chat/assistantCitationCommentDismissal.test.ts @@ -0,0 +1,80 @@ +import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; + +describe("resolveAssistantCitationCommentDismissal", () => { + it("commits typed text when the popover is dismissed by clicking away", () => { + expect( + resolveAssistantCitationCommentDismissal({ + reason: "outside-press", + draft: "needs a retry", + savedComment: undefined, + }), + ).toEqual({ kind: "commit", comment: "needs a retry" }); + }); + + it("commits an edited comment when focus leaves the popover", () => { + expect( + resolveAssistantCitationCommentDismissal({ + reason: "focus-out", + draft: "second thought", + savedComment: "first thought", + }), + ).toEqual({ kind: "commit", comment: "second thought" }); + }); + + it("closes without saving when nothing changed", () => { + expect( + resolveAssistantCitationCommentDismissal({ + reason: "outside-press", + draft: null, + savedComment: "kept", + }), + ).toEqual({ kind: "close" }); + expect( + resolveAssistantCitationCommentDismissal({ + reason: "outside-press", + draft: " kept ", + savedComment: "kept", + }), + ).toEqual({ kind: "close" }); + expect( + resolveAssistantCitationCommentDismissal({ + reason: "outside-press", + draft: "kept", + savedComment: " kept ", + }), + ).toEqual({ kind: "close" }); + }); + + it("clears a comment when the draft was emptied", () => { + expect( + resolveAssistantCitationCommentDismissal({ + reason: "trigger-press", + draft: "", + savedComment: "old", + }), + ).toEqual({ kind: "commit", comment: "" }); + }); + + it("keeps Escape as an explicit discard", () => { + expect( + resolveAssistantCitationCommentDismissal({ + reason: "escape-key", + draft: "unsaved", + savedComment: undefined, + }), + ).toEqual({ kind: "close" }); + }); + + it("keeps the popover open instead of dropping an over-length draft", () => { + expect( + resolveAssistantCitationCommentDismissal({ + reason: "outside-press", + draft: "x".repeat(ASSISTANT_CITATION_MAX_COMMENT_LENGTH + 1), + savedComment: undefined, + }), + ).toEqual({ kind: "keep-open" }); + }); +}); diff --git a/apps/web/src/components/chat/assistantCitationCommentDismissal.ts b/apps/web/src/components/chat/assistantCitationCommentDismissal.ts new file mode 100644 index 000000000000..a6fe2ca3bcb6 --- /dev/null +++ b/apps/web/src/components/chat/assistantCitationCommentDismissal.ts @@ -0,0 +1,21 @@ +import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH } from "@t3tools/contracts"; + +export type AssistantCitationCommentDismissal = + | { kind: "commit"; comment: string } + | { kind: "close" } + | { kind: "keep-open" }; + +export function resolveAssistantCitationCommentDismissal({ + reason, + draft, + savedComment, +}: { + reason: string; + draft: string | null; + savedComment: string | undefined; +}): AssistantCitationCommentDismissal { + if (reason === "escape-key" || draft === null) return { kind: "close" }; + if (draft.trim() === (savedComment ?? "").trim()) return { kind: "close" }; + if (draft.length > ASSISTANT_CITATION_MAX_COMMENT_LENGTH) return { kind: "keep-open" }; + return { kind: "commit", comment: draft }; +} diff --git a/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts b/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts index 052c9b1d9f4d..8920bd52d2b1 100644 --- a/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts +++ b/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts @@ -20,7 +20,13 @@ function measurePicker(input: { clientWidth: number; flexGrow: string; maxWidth? } return null; }, - querySelectorAll: () => [{ getBoundingClientRect: () => ({ width: 140 }) }], + querySelectorAll: () => [ + { + dataset: {}, + querySelectorAll: () => [], + getBoundingClientRect: () => ({ width: 140 }), + }, + ], }; vi.stubGlobal("getComputedStyle", (element: unknown) => { if (element === label) return { flexGrow: input.flexGrow }; @@ -40,6 +46,7 @@ describe("measureRestingComposerControls", () => { expect(resolveRestingComposerControlsNaturalWidth(measurement)).toBe(196); expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({ hiddenCount: 0, + iconOnlyCount: 0, visible: true, }); }); @@ -50,6 +57,7 @@ describe("measureRestingComposerControls", () => { expect(measurement.naturalFixedWidth).toBe(192); expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({ hiddenCount: 1, + iconOnlyCount: 1, visible: true, }); }); @@ -60,6 +68,7 @@ describe("measureRestingComposerControls", () => { expect(measurement.naturalFixedWidth).toBe(212); expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({ hiddenCount: 1, + iconOnlyCount: 1, visible: true, }); }); diff --git a/apps/web/src/components/chat/restingComposerControlsMeasurement.ts b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts index fc39951723fa..82aa882db53a 100644 --- a/apps/web/src/components/chat/restingComposerControlsMeasurement.ts +++ b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts @@ -42,6 +42,33 @@ function providerModelPickerMinimumWidth(picker: HTMLElement): number { return minWidth + elementInlineMarginWidth(picker); } +function controlBlockWidths(block: HTMLElement): { natural: number; iconOnly: number } { + const compact = block.dataset.composerBlockIconOnly === "true"; + let natural = elementOuterWidth(block); + let iconOnly = natural; + for (const label of block.querySelectorAll("[data-composer-control-label]")) { + // Labels remain mounted at natural width when icons replace them. Reading + // both variants from one tree avoids duplicate controls or write/read probes. + const labelStyle = getComputedStyle(label); + const inFlow = labelStyle.position !== "absolute"; + // Phone widths already hide the build label with sr-only. Its clipping + // remains in effect even when compact styles replace its one-pixel width. + if (!inFlow && (!compact || labelStyle.clip !== "auto")) continue; + const labelWidth = label.scrollWidth; + const renderedWidth = inFlow ? label.getBoundingClientRect().width : 0; + const gap = Number.parseFloat(getComputedStyle(label.parentElement!).columnGap) || 0; + natural += labelWidth - renderedWidth + (inFlow ? 0 : gap); + iconOnly -= renderedWidth + (inFlow ? gap : 0); + } + for (const icon of block.querySelectorAll("[data-composer-control-compact-icon]")) { + const width = elementOuterWidth(icon); + const gap = Number.parseFloat(getComputedStyle(icon.parentElement!).columnGap) || 0; + natural -= compact ? width + gap : 0; + iconOnly += compact ? 0 : width + gap; + } + return { natural, iconOnly: Math.min(natural, iconOnly) }; +} + /** * Read the natural widths of the resting composer controls from the DOM. * @@ -67,6 +94,7 @@ export function measureRestingComposerControls( const overflow = controls.querySelector("[data-resting-controls-overflow]"); const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0; const blocks = Array.from(controls.querySelectorAll("[data-resting-block]")); + const widths = blocks.map(controlBlockWidths); return { gap, naturalFixedWidth: @@ -75,7 +103,8 @@ export function measureRestingComposerControls( minimumFixedWidth: (picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) + separatorAndGapWidth, - blockWidths: blocks.map(elementOuterWidth), + blockWidths: widths.map((width) => width.natural), + iconOnlyBlockWidths: widths.map((width) => width.iconOnly), overflowWidth: overflow ? elementOuterWidth(overflow) : 0, }; } diff --git a/apps/web/src/components/chat/useComposerTriggerState.test.tsx b/apps/web/src/components/chat/useComposerTriggerState.test.tsx new file mode 100644 index 000000000000..6daf88bfbd67 --- /dev/null +++ b/apps/web/src/components/chat/useComposerTriggerState.test.tsx @@ -0,0 +1,145 @@ +import { act, StrictMode, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { detectComposerTrigger } from "../../composer-logic"; +import { useComposerTriggerState } from "./useComposerTriggerState"; + +const command = "pnpm install -g @openai/codex@latest"; +const initialPrompt = "pnpm install -g @openai"; +let root: Root; +let composer: ReturnType; + +function ComposerProbe() { + const state = useComposerTriggerState(() => + detectComposerTrigger(initialPrompt, initialPrompt.length), + ); + useLayoutEffect(() => { + composer = state; + }); + return null; +} + +async function updatePrompt(text: string, cursor = text.length) { + await act(() => composer.setTrigger(detectComposerTrigger(text, cursor))); +} + +beforeEach(async () => { + // The probe renders no DOM nodes, but ReactDOM still needs an event target. + const document = { + nodeType: 9, + addEventListener() {}, + removeEventListener() {}, + }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); + await act(() => + root.render( + + + , + ), + ); +}); + +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); + +describe("composer suggestion dismissal", () => { + it("closes suggestions and rejects keyboard selection before the next render", async () => { + const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length); + expect(composer.trigger).toEqual(candidate); + + await act(() => { + composer.dismissTrigger(candidate); + expect(composer.resolveTrigger(candidate)).toBeNull(); + }); + expect(composer.trigger).toBeNull(); + }); + + it("stays dismissed while typing a scoped package, including its second @", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + + for (let cursor = initialPrompt.length; cursor <= command.length; cursor += 1) { + const text = command.slice(0, cursor); + await updatePrompt(text); + expect(composer.trigger).toBeNull(); + expect(composer.resolveTrigger(detectComposerTrigger(text, cursor))).toBeNull(); + } + }); + + it("stays dismissed while deleting characters or moving within the same word", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + + for (let cursor = initialPrompt.length - 1; cursor > initialPrompt.indexOf("@"); cursor -= 1) { + await updatePrompt(initialPrompt, cursor); + expect(composer.trigger).toBeNull(); + await updatePrompt(initialPrompt.slice(0, cursor)); + expect(composer.trigger).toBeNull(); + } + }); + + it("opens suggestions for a new @ word after a space", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(`${command} `); + await updatePrompt(`${command} @src`); + + expect(composer.trigger?.query).toBe("src"); + expect(composer.trigger?.rangeStart).toBe(command.length + 1); + }); + + it("opens a different token when the caret moves directly to it", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(`${command} @src`); + + expect(composer.trigger?.query).toBe("src"); + }); + + it("can reopen after the caret leaves the dismissed word", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(initialPrompt, 0); + await updatePrompt(initialPrompt); + + expect(composer.trigger?.query).toBe("openai"); + }); + + it("can reopen at the same position after deleting and retyping @", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + const prefix = initialPrompt.slice(0, initialPrompt.indexOf("@")); + await updatePrompt(prefix); + await updatePrompt(`${prefix}@`); + + expect(composer.trigger?.query).toBe(""); + }); + + it("clears dismissal when switching drafts or pending questions", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length); + await act(() => composer.resetTrigger(candidate)); + + expect(composer.trigger).toEqual(candidate); + expect(composer.resolveTrigger(candidate)).toEqual(candidate); + }); + + it.each(["/plan", "$skill", "#123"])("also dismisses %s suggestions", async (text) => { + await updatePrompt(text); + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(`${text}x`); + + expect(composer.trigger).toBeNull(); + await updatePrompt("@src"); + expect(composer.trigger?.kind).toBe("path"); + }); +}); diff --git a/apps/web/src/components/chat/useComposerTriggerState.ts b/apps/web/src/components/chat/useComposerTriggerState.ts new file mode 100644 index 000000000000..255a8c43735c --- /dev/null +++ b/apps/web/src/components/chat/useComposerTriggerState.ts @@ -0,0 +1,42 @@ +import { useCallback, useRef, useState } from "react"; + +import type { ComposerTrigger } from "../../composer-logic"; + +/** Keep a dismissed suggestion closed until the caret leaves its token. */ +export function useComposerTriggerState(initialTrigger: () => ComposerTrigger | null) { + const [trigger, setActiveTrigger] = useState(initialTrigger); + const dismissedTriggerRef = useRef(null); + + const resolveTrigger = useCallback((candidate: ComposerTrigger | null) => { + const dismissed = dismissedTriggerRef.current; + return candidate && + dismissed && + candidate.kind === dismissed.kind && + candidate.rangeStart === dismissed.rangeStart + ? null + : candidate; + }, []); + + const setTrigger = useCallback( + (candidate: ComposerTrigger | null) => { + const activeTrigger = resolveTrigger(candidate); + if (candidate === null || activeTrigger !== null) { + dismissedTriggerRef.current = null; + } + setActiveTrigger(activeTrigger); + }, + [resolveTrigger], + ); + + const dismissTrigger = useCallback((candidate: ComposerTrigger | null) => { + dismissedTriggerRef.current = candidate; + setActiveTrigger(null); + }, []); + + const resetTrigger = useCallback((candidate: ComposerTrigger | null) => { + dismissedTriggerRef.current = null; + setActiveTrigger(candidate); + }, []); + + return { trigger, setTrigger, resolveTrigger, dismissTrigger, resetTrigger }; +} diff --git a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx index 2ddf8f56dd2a..4b70f8008823 100644 --- a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx +++ b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx @@ -56,7 +56,7 @@ export function ClerkUserProfileRefreshButton({ disabled={disabled || isPending} onClick={onClick} > -
); } diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index b9a880301831..a6a556231e18 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -20,7 +20,12 @@ const EMPTY_PROJECT_FILE_PATH = ""; const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make( AsyncResult.initial(false), ).pipe(Atom.withLabel("project-file-query:empty")); -function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) { +/** A pending in-app write to the file, overlaying the query until confirmed. */ +export function optimisticFileAtom( + environmentId: EnvironmentId, + cwd: string, + relativePath: string, +) { return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath }); } diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 5c6517e154a1..7e8dea7f2dea 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -1162,7 +1162,7 @@ function ImportStep({

Your projects

- +

Looking for projects from Claude Code and Codex…

@@ -1229,7 +1229,7 @@ function ImportStep({ ) : null} {scan.isPending && scan.data === null ? (
- + Looking for projects…
) : scan.error !== null ? ( diff --git a/apps/web/src/components/preview/AgentBrowserCursor.tsx b/apps/web/src/components/preview/AgentBrowserCursor.tsx index bc89daee4595..408f268fa0c4 100644 --- a/apps/web/src/components/preview/AgentBrowserCursor.tsx +++ b/apps/web/src/components/preview/AgentBrowserCursor.tsx @@ -24,7 +24,6 @@ export function AgentBrowserCursor(props: { return ( (null); + const active = inactiveSequence !== event.sequence; useEffect(() => { - const timeout = window.setTimeout(() => setActive(false), CURSOR_ACTIVE_MS); + const timeout = window.setTimeout(() => setInactiveSequence(event.sequence), CURSOR_ACTIVE_MS); return () => window.clearTimeout(timeout); - }, []); + }, [event.sequence]); return (
({ pictureInPicture: false, showEmptyState: false, loading: false, + serverEpoch: null as string | null, + recordingTabIds: new Set(), + recordingRuntimeTabId: null as string | null, recordVisitForThread: vi.fn(), })); @@ -98,6 +101,7 @@ vi.mock("~/previewStateStore", () => ({ updatePreviewServerSnapshot: vi.fn(), useThreadPreviewState: () => ({ activeTabId: "tab-1", + serverEpoch: mocks.serverEpoch, desktopByTabId: { "tab-1": { hasWebContents: true, @@ -146,11 +150,11 @@ vi.mock("~/state/use-atom-command", () => ({ })); vi.mock("~/browser/browserRecording", () => ({ - findActiveBrowserRecordingRuntimeTabId: vi.fn(() => null), + findActiveBrowserRecordingRuntimeTabId: () => mocks.recordingRuntimeTabId, isBrowserRecordingStartCancelledError: vi.fn(() => false), startBrowserRecording: vi.fn(), stopBrowserRecording: vi.fn(), - useActiveBrowserRecordingTabIds: () => new Set(), + useActiveBrowserRecordingTabIds: () => mocks.recordingTabIds, })); vi.mock("~/browser/browserSurfaceStore", () => ({ @@ -244,7 +248,9 @@ vi.mock("./PreviewMoreMenu", () => ({ })); vi.mock("./PreviewUnreachable", () => ({ PreviewUnreachable: () => null })); vi.mock("./ZoomIndicator", () => ({ ZoomIndicator: () => null })); -vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); +vi.mock("./AgentBrowserCursor", () => ({ + AgentBrowserCursor: () => createElement("agent-cursor"), +})); vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() })); @@ -344,9 +350,38 @@ describe("PreviewView navigation", () => { mocks.pictureInPicture = false; mocks.showEmptyState = false; mocks.loading = false; + mocks.serverEpoch = null; + mocks.recordingTabIds = new Set(); + mocks.recordingRuntimeTabId = null; mocks.recordVisitForThread.mockClear(); }); + it("shows the cursor in a replacement browser while the old instance still records", async () => { + const document = installTestDom(); + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + const root = createRoot(container as unknown as Element); + const hasCursor = (node: TestNode): boolean => + node.nodeName === "AGENT-CURSOR" || node.childNodes.some(hasCursor); + mocks.recordingTabIds.add(TEST_RUNTIME_TAB_ID); + mocks.recordingRuntimeTabId = TEST_RUNTIME_TAB_ID; + try { + await act(() => { + root.render(); + }); + expect(hasCursor(container)).toBe(false); + mocks.serverEpoch = "replacement-server"; + await act(() => { + root.render(); + }); + expect(hasCursor(container)).toBe(true); + expect(mocks.recordingTabIds.has(TEST_RUNTIME_TAB_ID)).toBe(true); + } finally { + await act(() => root.unmount()); + vi.unstubAllGlobals(); + } + }); + it("does not rerender while loading time passes", async () => { vi.useFakeTimers(); mocks.loading = true; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index d190a78d4825..810e6d502804 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -798,18 +798,17 @@ export function PreviewView({ {snapshot && desktopOverlay ? ( ) : null} - {runtimeTabId && desktopOverlay && !showEmptyState && !isUnreachable ? ( + {runtimeTabId && + desktopOverlay && + !showEmptyState && + !isUnreachable && + !activeRecordingTabIds.has(runtimeTabId) ? ( ) : null} - {controller !== "none" ? ( -
- {controller === "agent" ? "Agent controlling browser" : "Human control"} -
- ) : null} {navStatus._tag === "LoadFailed" ? (
Could not load pull request activity

{error}

diff --git a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx index 20100339ecbb..e9797016aad7 100644 --- a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx +++ b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx @@ -109,6 +109,7 @@ function ChecksBody({ export function PullRequestChecksPopover({ checksState, checks, + stale = false, environmentId, reference, threadRef = null, @@ -117,6 +118,7 @@ export function PullRequestChecksPopover({ checksState: PullRequestChecksState; /** The checks already in hand, for the detail header. Absent on a listing row. */ checks?: ReadonlyArray; + stale?: boolean; environmentId?: EnvironmentId; reference?: PullRequestRef; /** Thread the popover sits beside; a listing row has none. */ @@ -125,7 +127,7 @@ export function PullRequestChecksPopover({ }) { const presentation = pullRequestChecksStatePresentation(checksState); // Counts beat the rollup's own wording where they are known, the way GitHub's own header reads. - const summary = checks === undefined ? null : summarizePullRequestChecks(checks); + const summary = checks === undefined || stale ? null : summarizePullRequestChecks(checks); return ( {/* A listing row is itself a button, so the trigger renders as a span: a nested button is @@ -148,7 +150,11 @@ export function PullRequestChecksPopover({

{presentation.label}

{summary === null ? null :

{summary}

} - {checks !== undefined ? ( + {stale ? ( +

+ Check details are out of date. Refresh the pull request to update them. +

+ ) : checks !== undefined ? ( ) : environmentId !== undefined && reference !== undefined ? ( (null); const [threadPending, setThreadPending] = useState(false); const [orphansOpen, setOrphansOpen] = useState(false); - // Closed by default so the review form does not permanently eat vertical space below the - // diff; opened on demand as a floating overlay instead. - const [reviewOpen, setReviewOpen] = useState(false); // Which pull request the slices belong to travels with them, so a render taken before the // reset below cannot read the previous one's slices — or send its cursor to the host. const [sliceState, setSliceState] = useState<{ @@ -376,7 +372,6 @@ function PullRequestCodeTab({ inlineComment: hostReview.inlineComment && viewer.comment, reply: hostReview.reply && viewer.comment, resolve: hostReview.resolve && viewer.resolve, - verdicts: hostReview.verdicts.filter((verdict) => viewer.verdicts.includes(verdict)), }; }, [detail.capabilities.review, detail.viewerPermissions]); // A comment is posted against the pull request's head diff, so a line number taken from one @@ -390,16 +385,17 @@ function PullRequestCodeTab({ loadedSlices.map((slice) => { // The patch's own hash is part of the key: a refreshed page reuses its cursor, and a // key of position alone would keep handing back the parse of the patch it replaced. - const cacheKey = `pull-request:${scopeKey}:${resolvedTheme}:${slice.cursor ?? "first"}:${fnv1a32(slice.patch)}`; + const cacheKey = `pull-request:${scopeKey}:${resolvedTheme}:${ignoreWhitespace}:${slice.cursor ?? "first"}:${fnv1a32(slice.patch)}`; const cached = parseCache.current.get(cacheKey); if (cached) return cached; const parsed = getRenderablePatch(slice.patch, cacheKey, { compactPartialHunkOffsets: true, + ignoreWhitespace, }); if (parsed) parseCache.current.set(cacheKey, parsed); return parsed; }), - [loadedSlices, resolvedTheme, scopeKey], + [loadedSlices, resolvedTheme, scopeKey, ignoreWhitespace], ); // Ordered within a slice rather than across them: ordering the accumulated set would let a late // slice push a file the reader is part way through further down the page. @@ -702,7 +698,13 @@ function PullRequestCodeTab({ // that silently lost its first line on the other hosts would be worse than one line. const path = resolveFileDiffPath(file); const previousPath = resolveFileDiffPreviousPath(file); - const position = resolveDiffReviewPosition(file, range.end, range.endSide ?? range.side); + const sourceFile = parsedSlices + .flatMap((slice) => (slice?.kind === "files" ? slice.sourceFiles : [])) + .find((candidate) => resolveFileDiffPath(candidate) === path); + const side = range.endSide ?? range.side; + const position = + (sourceFile && resolveDiffReviewPosition(sourceFile, range.end, side)) ?? + resolveDiffReviewPosition(file, range.end, side); if (position === null) return; setDraft({ fileKey: item.id, @@ -712,7 +714,7 @@ function PullRequestCodeTab({ range, }); }, - [canCommentOnLines, files], + [canCommentOnLines, files, parsedSlices], ); // Built here because the parsed diff only lives here, and built by the same function the @@ -1045,70 +1047,6 @@ function PullRequestCodeTab({ ], ); - /** - * The review overlay belongs to the pull request, not to the patch: a change whose diff - * cannot be structured — or read at all — is still one a reviewer can approve or reject, so - * it survives every branch below. It floats over the scroll area rather than sitting in the - * layout flow, so the diff keeps the full height instead of permanently losing a strip to a - * footer most reviews never touch. Hidden entirely where the host offers no verdicts, same as - * the bar it wraps did. - */ - const reviewOverlay = - review.verdicts.length === 0 ? null : ( -
- {reviewOpen ? ( -
- - { - onRefresh(); - setReviewOpen(false); - }} - /> -
- ) : ( - - )} -
- ); // A rebase or a force-push can take the scoped commit out of the change. Its diff may still // be reachable on the host, but it is no longer part of what is being reviewed, so the scope // goes back to the whole change rather than sitting under a name nothing matches. @@ -1123,11 +1061,6 @@ function PullRequestCodeTab({ } }, [commit, onSelectedCommitChange, selectedCommit]); const scopeLabel = selectedCommit ? selectedCommit.messageHeadline : "All commits"; - /** - * The same controls the thread diff panel carries, in the same order, minus the - * ignore-whitespace toggle: that is `git diff -w` on the server, and no host's pull request - * diff API offers it. - */ const toolbar = (
@@ -1276,6 +1209,30 @@ function PullRequestCodeTab({
+ + { + setIgnoreWhitespace(Boolean(pressed)); + setDraft(null); + setSelectedLines(null); + }} + /> + } + > + + + + {ignoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} + + {fileKeys.length > 0 ? ( ( + const withToolbar = (body: ReactNode) => (
{toolbar} - {/* The overlay is anchored to this wrapper, not the scroller: absolute positioning - inside an overflowing element tracks the content's bottom edge, which would carry - the trigger away with the first scroll. */} -
-
{body}
- {reviewOverlay} -
+
{body}
); // Under the toolbar rather than in place of it, so choosing a commit does not take the // dropdown that was just used off the screen while its diff loads. if (diffQuery.isPending && loadedSlices.length === 0) { - return withReviewBar(); + return withToolbar(); } // A slice that fails once there are files on screen is reported at the end of them instead: // the diff already read is worth more than the error that stopped it growing. if (diffQuery.error && loadedSlices.length === 0) { - return withReviewBar( + return withToolbar(

{diffQuery.error}

, ); } @@ -1400,7 +1351,7 @@ function PullRequestCodeTab({ ? parsedSlices.flatMap((parsed) => (parsed?.kind === "raw" ? [parsed] : [])) : []; if (files.length === 0 && rawSlices.length > 0) { - return withReviewBar( + return withToolbar(
{rawSlices.map((slice) => (
@@ -1413,7 +1364,7 @@ function PullRequestCodeTab({ } if (items.length === 0 && nextCursor === null) { - return withReviewBar( + return withToolbar(

{commit === null ? "This pull request has no file changes." @@ -1567,7 +1518,6 @@ function PullRequestCodeTab({ renderAnnotation={renderAnnotation} unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS} /> - {reviewOverlay}

{fileTreeOpen ? (