diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 73376110d..053bfbed0 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -9,6 +9,8 @@ # -github:username reason for denouncement # # Keep entries sorted alphabetically. +github:0x4bs3nt +github:Adamulek123 github:adityavardhansharma github:binbandit github:chuks-qua @@ -16,16 +18,20 @@ github:cursoragent github:gbarros-dev github:github-actions[bot] github:hwanseoc +github:ipanasenko github:jamesx0416 github:jasonLaster github:JoeEverest github:maria-rcks +github:maxwellyoung +github:nateEc github:nmggithub github:Noojuno github:notkainoa github:PatrickBauer github:realAhmedRoach github:shiroyasha9 +github:tsouth89 github:Yash-Singh1 github:eggfriedrice24 github:Ymit24 diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 80390f512..8ff9f7b7e 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -11,6 +11,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; +import * as DesktopAppActivation from "./DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; @@ -148,6 +149,7 @@ const bootstrap = Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const desktopWindow = yield* DesktopWindow.DesktopWindow; + const appActivation = yield* DesktopAppActivation.DesktopAppActivation; yield* logBootstrapInfo("bootstrap start"); if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { @@ -210,6 +212,10 @@ const bootstrap = Effect.gen(function* () { } yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); + yield* appActivation.start.pipe( + Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), + Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), + ); // Bring up the WSL backend if the user previously enabled it. The // primary is already starting; reconcile fires off the WSL register // in parallel rather than blocking primary readiness on a possibly diff --git a/apps/desktop/src/app/DesktopAppActivation.test.ts b/apps/desktop/src/app/DesktopAppActivation.test.ts new file mode 100644 index 000000000..d6ce80322 --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivation.test.ts @@ -0,0 +1,140 @@ +// @effect-diagnostics nodeBuiltinImport:off -- This adapter test binds a real local socket or Windows named pipe and verifies its cleanup. +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { + ProjectId, + ThreadId, + type DesktopAppActivationRequest, + type DesktopAppActivationResponse, +} from "@t3tools/contracts"; +import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import { HostProcessPlatform, HostProcessUserId } from "@t3tools/shared/hostProcess"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { afterEach, describe, expect } from "vite-plus/test"; + +import { startDesktopAppControlServer } from "./DesktopAppActivation.ts"; + +const openServers: Array<{ close: () => Promise }> = []; + +afterEach(async () => { + await Promise.all(openServers.splice(0).map((server) => server.close())); +}); + +function makeTarget(stateDir: string, platform: NodeJS.Platform, userId: number | undefined) { + return resolveDesktopAppControlAddress({ + stateDir, + platform, + tempDir: NodeOS.tmpdir(), + userId, + joinPath: NodePath.join, + }); +} + +function request(requestId: string, platform: NodeJS.Platform): DesktopAppActivationRequest { + return { + version: 1, + requestId, + type: "open-workspace", + workspaceRoot: NodePath.join(NodeOS.tmpdir(), "project"), + platform: platform === "win32" ? "win32" : platform === "darwin" ? "darwin" : "linux", + }; +} + +function exchange(address: string, payload: DesktopAppActivationRequest) { + return new Promise((resolve, reject) => { + const socket = NodeNet.createConnection(address); + socket.setEncoding("utf8"); + let buffer = ""; + socket.once("error", reject); + socket.once("connect", () => socket.write(`${JSON.stringify(payload)}\n`)); + socket.on("data", (chunk) => { + buffer += chunk; + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + socket.destroy(); + resolve(JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationResponse); + }); + }); +} + +describe("desktop app control server", () => { + it.effect("roundtrips a request and removes its socket on shutdown", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-control-test-")); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + const received: DesktopAppActivationRequest[] = []; + const server = await startDesktopAppControlServer({ + ...target, + userId, + handle: async (input) => { + received.push(input); + return { + version: 1, + requestId: input.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }; + }, + cancel: () => undefined, + }); + openServers.push(server); + + const response = await exchange(target.address, request("request-1", platform)); + + expect(received).toHaveLength(1); + expect(response).toMatchObject({ ok: true, requestId: "request-1" }); + await server.close(); + openServers.splice(openServers.indexOf(server), 1); + if (target.directory !== null) { + await expect(NodeFSP.stat(target.address)).rejects.toMatchObject({ code: "ENOENT" }); + } + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); + + it.effect("cancels a queued request when the client disconnects", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-cancel-test-")); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + let resolveCanceled: (requestId: string) => void = () => undefined; + const canceled = new Promise((resolve) => { + resolveCanceled = resolve; + }); + const server = await startDesktopAppControlServer({ + ...target, + userId, + handle: () => new Promise(() => undefined), + cancel: resolveCanceled, + }); + openServers.push(server); + const socket = NodeNet.createConnection(target.address); + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.once("connect", () => { + socket.write(`${JSON.stringify(request("request-canceled", platform))}\n`, () => { + socket.destroy(); + resolve(); + }); + }); + }); + + await expect(canceled).resolves.toBe("request-canceled"); + await server.close(); + openServers.splice(openServers.indexOf(server), 1); + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts new file mode 100644 index 000000000..f63fdffed --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -0,0 +1,306 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat uid and an atomic stale-socket unlink at the Node adapter boundary. +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; + +import { + DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + DesktopAppActivationRequest, + type DesktopAppActivationResponse, +} from "@t3tools/contracts"; +import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import { HostProcessUserId } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import type * as Electron from "electron"; + +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL } from "../ipc/channels.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +const MAX_REQUEST_BYTES = 64 * 1024; +const REQUEST_TIMEOUT_MS = 15_000; +const isDesktopAppActivationRequest = Schema.is(DesktopAppActivationRequest); + +export class DesktopAppActivationStartError extends Schema.TaggedErrorClass()( + "DesktopAppActivationStartError", + { + address: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not start the desktop app control socket at ${this.address}.`; + } +} + +interface RunningControlServer { + readonly close: () => Promise; +} + +function invalidResponse(requestId: string, message: string): DesktopAppActivationResponse { + return { + version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + requestId, + ok: false, + code: "invalid-request", + message, + }; +} + +function requestIdFromUnknown(value: unknown): string { + if ( + typeof value === "object" && + value !== null && + "requestId" in value && + typeof value.requestId === "string" && + value.requestId.trim().length > 0 + ) { + return value.requestId; + } + return "invalid-request"; +} + +async function prepareUnixSocket(input: { + readonly address: string; + readonly directory: string; + readonly userId: number | undefined; +}): Promise { + await NodeFSP.mkdir(input.directory, { recursive: true, mode: 0o700 }); + const stat = await NodeFSP.lstat(input.directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`${input.directory} is not a directory.`); + } + if (input.userId !== undefined && stat.uid !== input.userId) { + throw new Error(`${input.directory} is owned by another user.`); + } + await NodeFSP.chmod(input.directory, 0o700); + await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); +} + +export async function startDesktopAppControlServer(input: { + readonly address: string; + readonly directory: string | null; + readonly userId: number | undefined; + readonly handle: (request: DesktopAppActivationRequest) => Promise; + readonly cancel: (requestId: string) => void; +}): Promise { + if (input.directory !== null) { + await prepareUnixSocket({ + address: input.address, + directory: input.directory, + userId: input.userId, + }); + } + + const sockets = new Set(); + const server = NodeNet.createServer((socket) => { + sockets.add(socket); + socket.setEncoding("utf8"); + let buffer = ""; + let handled = false; + let responseSent = false; + let activeRequestId: string | null = null; + + socket.setTimeout(5_000, () => socket.destroy()); + + const finish = (response: DesktopAppActivationResponse) => { + responseSent = true; + if (!socket.destroyed) socket.end(`${JSON.stringify(response)}\n`); + }; + + socket.on("data", (chunk) => { + if (handled) return; + buffer += chunk; + if (Buffer.byteLength(buffer, "utf8") > MAX_REQUEST_BYTES) { + handled = true; + finish(invalidResponse("invalid-request", "The desktop app request is too large.")); + return; + } + + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + handled = true; + socket.setTimeout(0); + const line = buffer.slice(0, newline); + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + finish(invalidResponse("invalid-request", "The desktop app request is not valid JSON.")); + return; + } + + if (!isDesktopAppActivationRequest(parsed)) { + finish( + invalidResponse(requestIdFromUnknown(parsed), "The desktop app request is invalid."), + ); + return; + } + activeRequestId = parsed.requestId; + void input.handle(parsed).then(finish, () => { + finish( + invalidResponse(parsed.requestId, "T3 Code could not process the desktop app request."), + ); + }); + }); + socket.on("error", () => socket.destroy()); + socket.on("close", () => { + sockets.delete(socket); + if (!responseSent && activeRequestId !== null) input.cancel(activeRequestId); + }); + }); + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.removeListener("listening", onListening); + reject(error); + }; + const onListening = () => { + server.removeListener("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(input.address); + }); + + try { + if (input.directory !== null) { + await NodeFSP.chmod(input.address, 0o600); + } + } catch (error) { + await new Promise((resolve) => server.close(() => resolve())); + throw error; + } + + let closed = false; + return { + close: async () => { + if (closed) return; + closed = true; + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + server.removeAllListeners(); + if (input.directory !== null) { + await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } + }, + }; +} + +export class DesktopAppActivation extends Context.Service< + DesktopAppActivation, + { + readonly start: Effect.Effect; + readonly setRendererReady: (ready: boolean) => Effect.Effect; + readonly complete: (response: DesktopAppActivationResponse) => Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopAppActivation") {} + +const { logWarning } = makeComponentLogger("desktop-app-activation"); + +export const make = Effect.gen(function* () { + const desktopEnvironment = yield* DesktopEnvironment.DesktopEnvironment; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const path = yield* Path.Path; + const userId = yield* HostProcessUserId; + const runPromise = Effect.runPromiseWith(yield* Effect.context()); + const address = resolveDesktopAppControlAddress({ + stateDir: path.resolve(desktopEnvironment.stateDir), + platform: desktopEnvironment.platform, + tempDir: NodeOS.tmpdir(), + userId, + joinPath: path.join, + }); + let registeredWebContents: Electron.WebContents | null = null; + let detachRendererListeners: (() => void) | null = null; + + const broker = new DesktopAppActivationBroker({ + requestTimeoutMs: REQUEST_TIMEOUT_MS, + activate: () => { + void runPromise( + desktopWindow.activate.pipe( + Effect.catchCause((cause) => logWarning("failed to focus the desktop window", { cause })), + ), + ); + }, + }); + + const clearRegisteredRenderer = () => { + detachRendererListeners?.(); + detachRendererListeners = null; + registeredWebContents = null; + broker.clearRenderer(); + }; + + return DesktopAppActivation.of({ + start: Effect.acquireRelease( + Effect.tryPromise({ + try: () => + startDesktopAppControlServer({ + ...address, + userId, + handle: (request) => broker.request(request), + cancel: (requestId) => broker.cancel(requestId), + }), + catch: (cause) => new DesktopAppActivationStartError({ address: address.address, cause }), + }), + (server) => + Effect.promise(() => server.close()).pipe( + Effect.catchCause((cause) => + logWarning("failed to close the desktop app control socket", { cause }), + ), + Effect.ensuring(Effect.sync(() => broker.close())), + ), + ).pipe(Effect.asVoid), + setRendererReady: Effect.fn("DesktopAppActivation.setRendererReady")(function* (ready) { + if (!ready) { + clearRegisteredRenderer(); + return; + } + const main = yield* electronWindow.main; + if (Option.isNone(main)) return; + const webContents = main.value.webContents; + if (webContents.isDestroyed()) return; + + if (registeredWebContents !== webContents) { + clearRegisteredRenderer(); + registeredWebContents = webContents; + const onUnavailable = () => clearRegisteredRenderer(); + const onNavigation = ( + event: Electron.Event, + ) => { + if (event.isMainFrame && !event.isSameDocument) clearRegisteredRenderer(); + }; + webContents.on("did-start-navigation", onNavigation); + webContents.once("destroyed", onUnavailable); + detachRendererListeners = () => { + webContents.removeListener("did-start-navigation", onNavigation); + webContents.removeListener("destroyed", onUnavailable); + }; + } + + broker.registerRenderer((request) => { + webContents.send(DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, request); + }); + }), + complete: (response) => Effect.sync(() => broker.complete(response)), + }); +}); + +export const layer = Layer.effect(DesktopAppActivation, make); diff --git a/apps/desktop/src/app/DesktopAppActivationBroker.test.ts b/apps/desktop/src/app/DesktopAppActivationBroker.test.ts new file mode 100644 index 000000000..7a889c2e9 --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivationBroker.test.ts @@ -0,0 +1,130 @@ +import { ProjectId, ThreadId, type DesktopAppActivationRequest } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts"; + +const request: DesktopAppActivationRequest = { + version: 1, + requestId: "request-1", + type: "open-workspace", + workspaceRoot: "/workspace/project", + platform: "linux", +}; + +describe("DesktopAppActivationBroker", () => { + it("focuses immediately and waits for renderer readiness", async () => { + const activate = vi.fn(); + const send = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate }); + + const response = broker.request(request); + expect(activate).toHaveBeenCalledOnce(); + expect(send).not.toHaveBeenCalled(); + + broker.registerRenderer(send); + expect(send).toHaveBeenCalledWith(request); + broker.complete({ + version: 1, + requestId: request.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + + await expect(response).resolves.toMatchObject({ ok: true, projectId: "project-1" }); + broker.close(); + }); + + it("fails an in-flight request when the renderer goes away", async () => { + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + broker.registerRenderer(vi.fn()); + + const response = broker.request(request); + broker.clearRenderer(); + + await expect(response).resolves.toMatchObject({ + ok: false, + code: "renderer-unavailable", + }); + broker.close(); + }); + + it("queues requests after unsubscribe until a new renderer registers", async () => { + const previousSend = vi.fn(); + const nextSend = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + broker.registerRenderer(previousSend); + broker.clearRenderer(); + + const response = broker.request(request); + expect(previousSend).not.toHaveBeenCalled(); + expect(nextSend).not.toHaveBeenCalled(); + + broker.registerRenderer(nextSend); + expect(nextSend).toHaveBeenCalledWith(request); + broker.complete({ + version: 1, + requestId: request.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + + await expect(response).resolves.toMatchObject({ ok: true }); + broker.close(); + }); + + it("removes a queued request when its CLI connection closes", async () => { + const send = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + + const response = broker.request(request); + broker.cancel(request.requestId); + broker.registerRenderer(send); + + await expect(response).resolves.toMatchObject({ ok: false, code: "renderer-unavailable" }); + expect(send).not.toHaveBeenCalled(); + broker.close(); + }); + + it("never sends a canceled request that was queued behind another request", async () => { + const send = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + broker.registerRenderer(send); + const secondRequest = { ...request, requestId: "request-2" }; + + const firstResponse = broker.request(request); + const secondResponse = broker.request(secondRequest); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenLastCalledWith(request); + + broker.cancel(secondRequest.requestId); + broker.complete({ + version: 1, + requestId: request.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + + await expect(firstResponse).resolves.toMatchObject({ ok: true }); + await expect(secondResponse).resolves.toMatchObject({ ok: false }); + expect(send).toHaveBeenCalledTimes(1); + broker.close(); + }); + + it("times out a request without polling", async () => { + vi.useFakeTimers(); + try { + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + const response = broker.request(request); + + await vi.advanceTimersByTimeAsync(1_000); + + await expect(response).resolves.toMatchObject({ ok: false, code: "request-timeout" }); + broker.close(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/desktop/src/app/DesktopAppActivationBroker.ts b/apps/desktop/src/app/DesktopAppActivationBroker.ts new file mode 100644 index 000000000..221df9ca8 --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivationBroker.ts @@ -0,0 +1,146 @@ +// @effect-diagnostics globalTimers:off -- This protocol broker owns cancellable request deadlines outside the Effect runtime. +import { + DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + type DesktopAppActivationFailure, + type DesktopAppActivationRequest, + type DesktopAppActivationResponse, +} from "@t3tools/contracts"; + +interface PendingActivation { + readonly request: DesktopAppActivationRequest; + readonly resolve: (response: DesktopAppActivationResponse) => void; + readonly timeout: ReturnType; + dispatched: boolean; +} + +type RendererSender = (request: DesktopAppActivationRequest) => void; + +function failure( + requestId: string, + code: DesktopAppActivationFailure["code"], + message: string, +): DesktopAppActivationFailure { + return { + version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + requestId, + ok: false, + code, + message, + }; +} + +/** Holds CLI requests until the real desktop renderer is ready to handle them. */ +export class DesktopAppActivationBroker { + readonly #pending = new Map(); + readonly #requestTimeoutMs: number; + readonly #activate: () => void; + #renderer: RendererSender | null = null; + #closed = false; + + constructor(input: { readonly requestTimeoutMs: number; readonly activate: () => void }) { + this.#requestTimeoutMs = input.requestTimeoutMs; + this.#activate = input.activate; + } + + request(request: DesktopAppActivationRequest): Promise { + if (this.#closed) { + return Promise.resolve( + failure(request.requestId, "renderer-unavailable", "T3 Code is shutting down."), + ); + } + if (this.#pending.has(request.requestId)) { + return Promise.resolve( + failure(request.requestId, "invalid-request", "The request id is already in use."), + ); + } + + const response = new Promise((resolve) => { + const timeout = setTimeout(() => { + this.#settle( + failure( + request.requestId, + "request-timeout", + "The desktop app did not finish opening the project in time.", + ), + ); + }, this.#requestTimeoutMs); + this.#pending.set(request.requestId, { + request, + resolve, + timeout, + dispatched: false, + }); + }); + + this.#activate(); + this.#flush(); + return response; + } + + registerRenderer(send: RendererSender): void { + this.#renderer = send; + this.#flush(); + } + + clearRenderer(): void { + this.#renderer = null; + for (const pending of this.#pending.values()) { + if (pending.dispatched) { + this.#settle( + failure( + pending.request.requestId, + "renderer-unavailable", + "The T3 Code window closed before it opened the project.", + ), + ); + } + } + } + + complete(response: DesktopAppActivationResponse): void { + this.#settle(response); + } + + cancel(requestId: string): void { + this.#settle( + failure(requestId, "renderer-unavailable", "The command closed before T3 Code was ready."), + ); + } + + close(): void { + this.#closed = true; + this.#renderer = null; + for (const pending of this.#pending.values()) { + this.#settle( + failure(pending.request.requestId, "renderer-unavailable", "T3 Code is shutting down."), + ); + } + } + + #flush(): void { + const renderer = this.#renderer; + if (renderer === null) return; + if ([...this.#pending.values()].some((pending) => pending.dispatched)) return; + + for (const pending of this.#pending.values()) { + if (pending.dispatched) continue; + try { + pending.dispatched = true; + renderer(pending.request); + } catch { + pending.dispatched = false; + this.#renderer = null; + } + return; + } + } + + #settle(response: DesktopAppActivationResponse): void { + const pending = this.#pending.get(response.requestId); + if (!pending) return; + clearTimeout(pending.timeout); + this.#pending.delete(response.requestId); + pending.resolve(response); + this.#flush(); + } +} diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 8e8317db7..124ee5095 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -21,6 +21,7 @@ import { fetchSshEnvironmentDescriptor, fetchSshSessionState, issueSshWebSocketTicket, + resolveSshHost, resolveSshPasswordPrompt, } from "./methods/sshEnvironment.ts"; import { @@ -45,12 +46,16 @@ import { showContextMenu, } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; +import * as AppActivationIpc from "./methods/appActivation.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; yield* PreviewIpc.installPreviewEventForwarding(); + yield* ipc.handle(AppActivationIpc.setReady); + yield* ipc.handle(AppActivationIpc.complete); + yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); @@ -64,6 +69,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(clearConnectionCatalog); yield* ipc.handle(discoverSshHosts); + yield* ipc.handle(resolveSshHost); yield* ipc.handle(ensureSshEnvironment); yield* ipc.handle(disconnectSshEnvironment); yield* ipc.handle(fetchSshEnvironmentDescriptor); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index c4ef82ec8..0e966431b 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -9,6 +9,9 @@ export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; +export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready"; +export const DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL = "desktop:app-activation-complete"; +export const DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL = "desktop:app-activation-request"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; @@ -26,6 +29,7 @@ export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog"; export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; +export const RESOLVE_SSH_HOST_CHANNEL = "desktop:resolve-ssh-host"; export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; diff --git a/apps/desktop/src/ipc/methods/appActivation.ts b/apps/desktop/src/ipc/methods/appActivation.ts new file mode 100644 index 000000000..b5e659b23 --- /dev/null +++ b/apps/desktop/src/ipc/methods/appActivation.ts @@ -0,0 +1,27 @@ +import { DesktopAppActivationResponse } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopAppActivation from "../../app/DesktopAppActivation.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const setReady = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, + payload: Schema.Boolean, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.appActivation.setReady")(function* (ready) { + const activation = yield* DesktopAppActivation.DesktopAppActivation; + yield* activation.setRendererReady(ready); + }), +}); + +export const complete = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL, + payload: DesktopAppActivationResponse, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.appActivation.complete")(function* (response) { + const activation = yield* DesktopAppActivation.DesktopAppActivation; + yield* activation.complete(response); + }), +}); diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 9c9af2a4e..cfb993d35 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -117,6 +117,16 @@ export const discoverSshHosts = DesktopIpc.makeIpcMethod({ }), }); +export const resolveSshHost = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RESOLVE_SSH_HOST_CHANNEL, + payload: Schema.String, + result: DesktopSshEnvironmentTargetSchema, + handler: Effect.fn("desktop.ipc.sshEnvironment.resolveHost")(function* (alias) { + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + return yield* sshEnvironment.resolveHost(alias); + }), +}); + export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, payload: DesktopSshEnvironmentEnsureInputSchema, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 14caeed8a..c826c56e1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -32,6 +32,7 @@ import * as ElectronTheme from "./electron/ElectronTheme.ts"; import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; +import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; @@ -157,6 +158,10 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( Layer.provideMerge(desktopPreviewLayer), ); +const desktopAppActivationLayer = DesktopAppActivation.layer.pipe( + Layer.provide(desktopWindowLayer), +); + // Pool layer instantiates the backend factory once for the Windows // primary instance and exposes it via pool.primary. Consumers go through // the pool now; the legacy DesktopBackendManager service is gone. The @@ -184,6 +189,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, + desktopAppActivationLayer, DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 407c7c3ef..bbfcc5173 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -56,6 +56,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), + resolveSshHost: (alias) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_HOST_CHANNEL, alias), ensureSshEnvironment: async (target, options) => unwrapEnsureSshEnvironmentResult( await ipcRenderer.invoke(IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, { @@ -164,6 +165,25 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); }; }, + appActivation: { + setReady: (ready) => + ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, ready), + complete: (response) => + ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL, response), + onRequest: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { + if (typeof request !== "object" || request === null) return; + listener(request as Parameters[0]); + }; + ipcRenderer.on(IpcChannels.DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener( + IpcChannels.DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, + wrappedListener, + ); + }; + }, + }, preview: { createTab: (tabId, defaults) => ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { diff --git a/apps/desktop/src/preview/PickLabelPosition.ts b/apps/desktop/src/preview/PickLabelPosition.ts deleted file mode 100644 index cf7f3c811..000000000 --- a/apps/desktop/src/preview/PickLabelPosition.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Pure clamp/flip math for the floating label that follows the cursor while - * the user is picking an element in the in-app browser. Lives in its own - * electron-free module so the geometry can be unit-tested without spinning - * up an Electron preload context (`PickPreload.ts` itself imports - * `electron` and `react-grab/primitives`, which can't load under vitest). - * - * - Horizontally pins the label to `targetLeft`, clamped into - * `[VIEWPORT_MARGIN, viewportWidth - labelWidth - VIEWPORT_MARGIN]`. - * - Vertically prefers above the target. If the label would overflow the - * top, flips below; if THAT also overflows the bottom, pins to the - * bottom margin (better to overlap the highlight than disappear). - */ - -/** Distance in CSS pixels between the highlight and the floating label. */ -export const LABEL_GAP = 4; -/** Minimum padding the label keeps from any viewport edge. */ -export const VIEWPORT_MARGIN = 4; - -export function computeLabelPosition(input: { - targetLeft: number; - targetTop: number; - targetBottom: number; - labelWidth: number; - labelHeight: number; - viewportWidth: number; - viewportHeight: number; -}): { x: number; y: number } { - const { targetLeft, targetTop, targetBottom, labelWidth, labelHeight } = input; - const { viewportWidth, viewportHeight } = input; - - let x = targetLeft; - const maxX = viewportWidth - labelWidth - VIEWPORT_MARGIN; - if (x > maxX) x = maxX; - if (x < VIEWPORT_MARGIN) x = VIEWPORT_MARGIN; - - let y = targetTop - labelHeight - LABEL_GAP; - if (y < VIEWPORT_MARGIN) { - y = targetBottom + LABEL_GAP; - if (y + labelHeight > viewportHeight - VIEWPORT_MARGIN) { - y = Math.max(VIEWPORT_MARGIN, viewportHeight - labelHeight - VIEWPORT_MARGIN); - } - } - - return { x, y }; -} diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 67800d7f0..0f4f66ab3 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -5,6 +5,7 @@ import type { } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import * as SshAuth from "@t3tools/ssh/auth"; +import { resolveSshTarget } from "@t3tools/ssh/command"; import { discoverSshHosts } from "@t3tools/ssh/config"; import { SshCommandError, @@ -54,6 +55,9 @@ export class DesktopSshEnvironment extends Context.Service< readonly discoverHosts: (input?: { readonly homeDir?: string; }) => Effect.Effect; + readonly resolveHost: ( + alias: string, + ) => Effect.Effect; readonly ensureEnvironment: ( target: DesktopSshEnvironmentTarget, options?: { readonly issuePairingToken?: boolean }, @@ -136,6 +140,11 @@ export const make = Effect.gen(function* () { Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.discoverHosts"), ), + resolveHost: (alias) => + resolveSshTarget(alias.trim()).pipe( + Effect.provide(runtimeContext), + Effect.withSpan("desktop.ssh.resolveHost"), + ), ensureEnvironment: (target, ensureOptions) => manager .ensureEnvironment(target, ensureOptions) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index dd3cd1aaf..b8c4e185e 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -336,6 +336,11 @@ describe("DesktopUpdates", () => { version: "1.2.4-nightly.20260709.765", note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821", }, + { version: "1.2.4-nightly.20260709.764", note: "- Change 764" }, + { version: "1.2.4-nightly.20260709.763", note: "- Change 763" }, + { version: "1.2.4-nightly.20260709.762", note: "- Change 762" }, + { version: "1.2.4-nightly.20260709.761", note: "- Change 761" }, + { version: "1.2.4-nightly.20260709.760", note: "- Change 760" }, ], }); yield* flushCallbacks; @@ -346,13 +351,21 @@ describe("DesktopUpdates", () => { { version: "1.2.4-nightly.20260709.766", items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"], + totalItems: 1, }, { version: "1.2.4-nightly.20260709.765", items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"], + totalItems: 1, }, + { version: "1.2.4-nightly.20260709.764", items: ["Change 764"], totalItems: 1 }, + { version: "1.2.4-nightly.20260709.763", items: ["Change 763"], totalItems: 1 }, + { version: "1.2.4-nightly.20260709.762", items: ["Change 762"], totalItems: 1 }, + { version: "1.2.4-nightly.20260709.761", items: ["Change 761"], totalItems: 1 }, ]); + assert.equal(state.omittedReleaseCount, 1); assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes); + assert.equal(harness.sentStates.at(-1)?.omittedReleaseCount, 1); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); @@ -383,8 +396,9 @@ describe("DesktopUpdates", () => { assert.equal(unchangedState.status, "downloaded"); assert.equal(unchangedState.downloadedVersion, "1.2.4"); assert.deepEqual(unchangedState.releaseNotes, [ - { version: "1.2.4", items: ["fix: queued update"] }, + { version: "1.2.4", items: ["fix: queued update"], totalItems: 1 }, ]); + assert.equal(unchangedState.omittedReleaseCount, 0); const nextResult = yield* updates.check("poll"); assert.isTrue(nextResult.checked); @@ -424,7 +438,10 @@ describe("DesktopUpdates", () => { assert.equal(state.status, "downloaded"); assert.equal(state.availableVersion, "1.2.4"); assert.equal(state.downloadedVersion, "1.2.4"); - assert.deepEqual(state.releaseNotes, [{ version: "1.2.4", items: ["fix: queued update"] }]); + assert.deepEqual(state.releaseNotes, [ + { version: "1.2.4", items: ["fix: queued update"], totalItems: 1 }, + ]); + assert.equal(state.omittedReleaseCount, 0); assert.equal(state.downloadPercent, 100); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); @@ -454,7 +471,10 @@ describe("DesktopUpdates", () => { assert.equal(state.status, "downloaded"); assert.equal(state.availableVersion, "1.2.4"); assert.equal(state.downloadedVersion, "1.2.4"); - assert.deepEqual(state.releaseNotes, [{ version: "1.2.4", items: ["fix: queued update"] }]); + assert.deepEqual(state.releaseNotes, [ + { version: "1.2.4", items: ["fix: queued update"], totalItems: 1 }, + ]); + assert.equal(state.omittedReleaseCount, 0); assert.equal(state.downloadPercent, 100); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 483ace0ff..a3d7da17b 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -593,14 +593,24 @@ export const make = Effect.gen(function* () { } const checkedAt = yield* currentIsoTimestamp; - const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version); + const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( + info.releaseNotes, + info.version, + ); yield* setState( - reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes), + reduceDesktopUpdateStateOnUpdateAvailable( + state, + info.version, + checkedAt, + releaseNotes, + omittedReleaseCount, + ), ); yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); yield* logUpdaterInfo("update available", { version: info.version, releaseNoteGroups: releaseNotes.length, + omittedReleaseCount, }); }), ), diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 78ea56e75..3ba2444dc 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -3,38 +3,137 @@ import { describe, expect, it } from "vite-plus/test"; import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; describe("normalizeDesktopUpdateReleaseNotes", () => { - it("splits a plain string note into items under the fallback version", () => { - const notes = normalizeDesktopUpdateReleaseNotes( - "## What's changed\n- First fix\n- Second fix", + it("shows the newest changes and counts all real changes", () => { + const result = normalizeDesktopUpdateReleaseNotes( + [ + "- feat: first change", + "- fix: second change", + "- fix: third change", + "- fix: fourth change", + "- fix: fifth change", + "- fix: sixth change", + "- fix: seventh change", + "- fix: eighth change", + "- fix(web): keep long task drawers usable on small screens by @human in #8313", + "- fix(opencode): handle child approvals, stops, and model catalogs by @human in #8480", + "## New Contributors", + "- @human made their first contribution in #8435", + "**Full Changelog**: https://github.com/pingdotgg/t3code/compare/old...new", + ].join("\n"), + "0.0.36-nightly.20260828.1213", + ); + + expect(result).toEqual({ + releaseNotes: [ + { + version: "0.0.36-nightly.20260828.1213", + items: [ + "fix(opencode): handle child approvals, stops, and model catalogs by @human in #8480", + "fix(web): keep long task drawers usable on small screens by @human in #8313", + "fix: eighth change", + "fix: seventh change", + "fix: sixth change", + "fix: fifth change", + "fix: fourth change", + "fix: third change", + ], + totalItems: 10, + }, + ], + omittedReleaseCount: 0, + }); + }); + + it("excludes a GitHub HTML contributor section", () => { + const result = normalizeDesktopUpdateReleaseNotes( + "

What's Changed

  • Older fix
  • Newer fix
" + + "

New Contributors

  • @human made their first contribution
" + + "

Full Changelog

", "1.2.3", ); - expect(notes).toEqual([{ version: "1.2.3", items: ["First fix", "Second fix"] }]); + + expect(result).toEqual({ + releaseNotes: [{ version: "1.2.3", items: ["Newer fix", "Older fix"], totalItems: 2 }], + omittedReleaseCount: 0, + }); + }); + + it("does not count Markdown or HTML section headings as changes", () => { + const changes = Array.from({ length: 8 }, (_, index) => `Change ${index + 1}`); + const result = normalizeDesktopUpdateReleaseNotes( + [ + { version: "1.2.4", note: ["### Features", ...changes].join("\n- ") }, + { + version: "1.2.3", + note: `

Fixes

    ${changes.map((change) => `
  • ${change}
  • `).join("")}
`, + }, + ], + "1.2.4", + ); + + expect(result.releaseNotes).toEqual([ + { version: "1.2.4", items: changes.toReversed(), totalItems: 8 }, + { version: "1.2.3", items: changes.toReversed(), totalItems: 8 }, + ]); }); - it("keeps per-version groups and drops empty ones", () => { - const notes = normalizeDesktopUpdateReleaseNotes( + it("keeps per-version order and drops empty groups", () => { + const result = normalizeDesktopUpdateReleaseNotes( [ - { version: "1.2.3", note: "- Newer change" }, + { version: "1.2.3", note: "- Newer release" }, { version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" }, - { version: "1.2.1", note: "- Older change" }, + { version: "1.2.1", note: "- Older release" }, ], "1.2.3", ); - expect(notes).toEqual([ - { version: "1.2.3", items: ["Newer change"] }, - { version: "1.2.1", items: ["Older change"] }, + + expect(result).toEqual({ + releaseNotes: [ + { version: "1.2.3", items: ["Newer release"], totalItems: 1 }, + { version: "1.2.1", items: ["Older release"], totalItems: 1 }, + ], + omittedReleaseCount: 0, + }); + }); + + it("counts valid groups before applying the six-release limit", () => { + const releaseNotes = [ + { version: "1.3.9", note: "- Change 9" }, + { version: "1.3.8", note: "Full changelog: https://example.com/compare/x...y" }, + { version: "1.3.7", note: "- Change 7" }, + { version: "1.3.6", note: "- Change 6" }, + { version: "1.3.5", note: "- Change 5" }, + { version: "1.3.4", note: "- Change 4" }, + { version: "1.3.3", note: "- Change 3" }, + { version: "1.3.2", note: "- Change 2" }, + ]; + + const result = normalizeDesktopUpdateReleaseNotes(releaseNotes, "1.3.9"); + + expect(result.releaseNotes.map(({ version }) => version)).toEqual([ + "1.3.9", + "1.3.7", + "1.3.6", + "1.3.5", + "1.3.4", + "1.3.3", ]); + expect(result.omittedReleaseCount).toBe(1); }); it("decodes valid HTML entities", () => { - const notes = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); - expect(notes).toEqual([{ version: "1.0.0", items: ["Fix & polish 😀"] }]); + const result = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); + expect(result).toEqual({ + releaseNotes: [{ version: "1.0.0", items: ["Fix & polish 😀"], totalItems: 1 }], + omittedReleaseCount: 0, + }); }); - it("ignores malformed entries instead of throwing", () => { - const notes = normalizeDesktopUpdateReleaseNotes( + it("ignores malformed and empty entries instead of throwing", () => { + const result = normalizeDesktopUpdateReleaseNotes( [ { version: "1.2.3", note: "- Valid change" }, + { version: "1.2.2", note: "" }, { version: 42, note: "- Bad version type" }, { version: "1.2.1", note: { html: "

object note

" } }, "not an object", @@ -42,23 +141,25 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { ], "1.2.3", ); - expect(notes).toEqual([{ version: "1.2.3", items: ["Valid change"] }]); + + expect(result).toEqual({ + releaseNotes: [{ version: "1.2.3", items: ["Valid change"], totalItems: 1 }], + omittedReleaseCount: 0, + }); }); - it("returns non-empty groups even when preceded by many boilerplate-only groups", () => { - const boilerplate = Array.from({ length: 7 }, (_, index) => ({ - version: `1.3.${9 - index}`, - note: "Full changelog: https://example.com/compare/x...y", - })); - const notes = normalizeDesktopUpdateReleaseNotes( - [...boilerplate, { version: "1.3.2", note: "- Older but real change" }], - "1.3.9", - ); - expect(notes).toEqual([{ version: "1.3.2", items: ["Older but real change"] }]); + it("returns an empty result for an invalid payload", () => { + expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0")).toEqual({ + releaseNotes: [], + omittedReleaseCount: 0, + }); }); it("does not throw on out-of-range numeric entities and keeps the literal", () => { - const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); - expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity �"] }]); + const result = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); + expect(result).toEqual({ + releaseNotes: [{ version: "1.0.0", items: ["Broken entity �"], totalItems: 1 }], + omittedReleaseCount: 0, + }); }); }); diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 69857c92b..3b2f32e64 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -59,6 +59,7 @@ function stripMarkup(input: string): string { input .replace(//gi, "\n") .replace(/]*>/gi, "\n- ") + .replace(/]*>/gi, (_, level: string) => `\n${"#".repeat(Number(level))} `) .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n") .replace(/<[^>]*>/g, "") .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") @@ -71,43 +72,60 @@ function truncateReleaseNoteItem(item: string): string { return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`; } -function isIgnoredReleaseNoteLine(line: string): boolean { - const normalized = line +function normalizeReleaseNoteLine(line: string): string { + return line .toLowerCase() .replace(/[*_`#]/g, "") .trim(); +} + +function isIgnoredReleaseNoteLine(line: string): boolean { + const normalized = normalizeReleaseNoteLine(line); return ( normalized === "" || normalized === "what's changed" || normalized === "whats changed" || - normalized === "full changelog" || - normalized === "new contributors" || normalized.startsWith("compare: ") || normalized.includes("/compare/") ); } -function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray { - if (!note) return []; +interface ExtractedReleaseNoteItems { + readonly items: ReadonlyArray; + readonly totalItems: number; +} + +function extractReleaseNoteItems(note: string | null | undefined): ExtractedReleaseNoteItems { + if (!note) return { items: [], totalItems: 0 }; const items: string[] = []; + let totalItems = 0; for (const rawLine of stripMarkup(note).split("\n")) { const item = rawLine .trim() .replace(/^[-*]\s+/, "") .replace(/^\d+[.)]\s+/, "") .replace(/\s+/g, " "); + const normalized = normalizeReleaseNoteLine(item); + if (normalized === "new contributors" || normalized === "full changelog") break; + if (/^#{1,6}\s+/.test(item)) continue; if (isIgnoredReleaseNoteLine(item)) continue; + totalItems += 1; items.push(truncateReleaseNoteItem(item)); - if (items.length >= MAX_RELEASE_NOTE_ITEMS_PER_GROUP) break; + if (items.length > MAX_RELEASE_NOTE_ITEMS_PER_GROUP) items.shift(); } - return items; + return { items: items.toReversed(), totalItems }; +} + +interface NormalizedDesktopUpdateReleaseNotes { + readonly releaseNotes: ReadonlyArray; + readonly omittedReleaseCount: number; } export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, -): ReadonlyArray { +): NormalizedDesktopUpdateReleaseNotes { const rawNotes = typeof releaseNotes === "string" ? [{ version: fallbackVersion, note: releaseNotes }] @@ -115,11 +133,20 @@ export function normalizeDesktopUpdateReleaseNotes( ? releaseNotes.filter(isElectronReleaseNoteInfo) : []; - return rawNotes - .map((entry) => ({ - version: entry.version, - items: extractReleaseNoteItems(entry.note), - })) - .filter((entry) => entry.items.length > 0) - .slice(0, MAX_RELEASE_NOTE_GROUPS); + const normalizedNotes = rawNotes.flatMap((entry) => { + const { items, totalItems } = extractReleaseNoteItems(entry.note); + if (totalItems === 0) return []; + return [ + { + version: entry.version, + items, + totalItems, + }, + ]; + }); + + return { + releaseNotes: normalizedNotes.slice(0, MAX_RELEASE_NOTE_GROUPS), + omittedReleaseCount: Math.max(0, normalizedNotes.length - MAX_RELEASE_NOTE_GROUPS), + }; } diff --git a/apps/desktop/src/updates/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts index e25da9e95..b3450c871 100644 --- a/apps/desktop/src/updates/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -62,7 +62,8 @@ describe("updateMachine", () => { status: "downloaded" as const, availableVersion: "1.1.0", downloadedVersion: "1.1.0", - releaseNotes: [{ version: "1.1.0", items: ["fix: queued update"] }], + releaseNotes: [{ version: "1.1.0", items: ["fix: queued update"], totalItems: 1 }], + omittedReleaseCount: 2, downloadPercent: 100, }; const checking = reduceDesktopUpdateStateOnCheckStart( @@ -78,14 +79,16 @@ describe("updateMachine", () => { expect(checking.status).toBe("checking"); expect(checking.downloadedVersion).toBe("1.1.0"); expect(checking.releaseNotes).toEqual(downloadedState.releaseNotes); + expect(checking.omittedReleaseCount).toBe(2); expect(failed.status).toBe("downloaded"); expect(failed.downloadedVersion).toBe("1.1.0"); expect(failed.releaseNotes).toEqual(downloadedState.releaseNotes); + expect(failed.omittedReleaseCount).toBe(2); expect(failed.message).toBeNull(); }); it("keeps the installer when the feed still offers its version", () => { - const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"] }]; + const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"], totalItems: 1 }]; const state = reduceDesktopUpdateStateOnUpdateAvailable( { ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), @@ -94,6 +97,7 @@ describe("updateMachine", () => { availableVersion: "1.1.0", downloadedVersion: "1.1.0", releaseNotes, + omittedReleaseCount: 2, downloadPercent: 100, }, "1.1.0", @@ -103,6 +107,7 @@ describe("updateMachine", () => { expect(state.status).toBe("downloaded"); expect(state.downloadedVersion).toBe("1.1.0"); expect(state.releaseNotes).toEqual(releaseNotes); + expect(state.omittedReleaseCount).toBe(2); expect(state.downloadPercent).toBe(100); }); @@ -147,7 +152,7 @@ describe("updateMachine", () => { }); it("preserves a downloaded update when no update is available", () => { - const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"] }]; + const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"], totalItems: 1 }]; const state = reduceDesktopUpdateStateOnNoUpdate( { ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), @@ -156,6 +161,7 @@ describe("updateMachine", () => { availableVersion: "1.1.0", downloadedVersion: "1.1.0", releaseNotes, + omittedReleaseCount: 2, message: "old failure", errorContext: "download", canRetry: true, @@ -167,6 +173,7 @@ describe("updateMachine", () => { expect(state.availableVersion).toBe("1.1.0"); expect(state.downloadedVersion).toBe("1.1.0"); expect(state.releaseNotes).toBe(releaseNotes); + expect(state.omittedReleaseCount).toBe(2); expect(state.downloadPercent).toBe(100); expect(state.message).toBeNull(); expect(state.errorContext).toBeNull(); @@ -180,7 +187,8 @@ describe("updateMachine", () => { enabled: true, status: "error", availableVersion: "1.1.0", - releaseNotes: [{ version: "1.1.0", items: ["fix: stale update"] }], + releaseNotes: [{ version: "1.1.0", items: ["fix: stale update"], totalItems: 1 }], + omittedReleaseCount: 2, message: "old failure", errorContext: "download", canRetry: true, @@ -192,6 +200,7 @@ describe("updateMachine", () => { expect(state.availableVersion).toBeNull(); expect(state.downloadedVersion).toBeNull(); expect(state.releaseNotes).toEqual([]); + expect(state.omittedReleaseCount).toBe(0); expect(state.message).toBeNull(); expect(state.errorContext).toBeNull(); }); @@ -201,6 +210,7 @@ describe("updateMachine", () => { { version: "1.1.0", items: ["feat: add update release notes"], + totalItems: 1, }, ]; const available = reduceDesktopUpdateStateOnUpdateAvailable( @@ -212,6 +222,7 @@ describe("updateMachine", () => { "1.1.0", "2026-03-04T00:00:00.000Z", releaseNotes, + 2, ); const downloading = reduceDesktopUpdateStateOnDownloadStart(available); const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); @@ -219,6 +230,7 @@ describe("updateMachine", () => { expect(available.status).toBe("available"); expect(available.channel).toBe("latest"); expect(available.releaseNotes).toBe(releaseNotes); + expect(available.omittedReleaseCount).toBe(2); expect(downloading.releaseNotes).toBe(releaseNotes); expect(downloading.status).toBe("downloading"); expect(downloading.downloadPercent).toBe(0); @@ -233,11 +245,13 @@ describe("updateMachine", () => { enabled: true, status: "available", availableVersion: "1.1.0-nightly.1", - releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"] }], + releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"], totalItems: 1 }], + omittedReleaseCount: 2, }, "2026-03-04T00:00:00.000Z", ); expect(state.releaseNotes).toEqual([]); + expect(state.omittedReleaseCount).toBe(0); }); }); diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index e51fe098a..2e9ed5199 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -31,6 +31,7 @@ export function createInitialDesktopUpdateState( availableVersion: null, downloadedVersion: null, releaseNotes: [], + omittedReleaseCount: 0, downloadPercent: null, checkedAt: null, message: null, @@ -49,6 +50,7 @@ export function reduceDesktopUpdateStateOnCheckStart( status: "checking", checkedAt, releaseNotes: hasDownloadedUpdate ? state.releaseNotes : [], + omittedReleaseCount: hasDownloadedUpdate ? state.omittedReleaseCount : 0, message: null, downloadPercent: hasDownloadedUpdate ? 100 : null, errorContext: null, @@ -89,16 +91,17 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( version: string, checkedAt: string, releaseNotes: ReadonlyArray = [], + omittedReleaseCount = 0, ): DesktopUpdateState { const isDownloadedVersion = state.downloadedVersion === version; - const nextReleaseNotes = - isDownloadedVersion && releaseNotes.length === 0 ? state.releaseNotes : releaseNotes; + const preserveReleaseNotes = isDownloadedVersion && releaseNotes.length === 0; return { ...state, status: isDownloadedVersion ? "downloaded" : "available", availableVersion: version, downloadedVersion: isDownloadedVersion ? version : null, - releaseNotes: nextReleaseNotes, + releaseNotes: preserveReleaseNotes ? state.releaseNotes : releaseNotes, + omittedReleaseCount: preserveReleaseNotes ? state.omittedReleaseCount : omittedReleaseCount, downloadPercent: isDownloadedVersion ? 100 : null, checkedAt, message: null, @@ -130,6 +133,7 @@ export function reduceDesktopUpdateStateOnNoUpdate( availableVersion: null, downloadedVersion: null, releaseNotes: [], + omittedReleaseCount: 0, downloadPercent: null, checkedAt, message: null, diff --git a/apps/mobile/modules/t3-markdown-text/index.ts b/apps/mobile/modules/t3-markdown-text/index.ts index 89bce5395..81b5f13f2 100644 --- a/apps/mobile/modules/t3-markdown-text/index.ts +++ b/apps/mobile/modules/t3-markdown-text/index.ts @@ -21,6 +21,8 @@ export { type MarkdownHighlightedToken, } from "./src/SelectableMarkdownText"; export type { + MarkdownFileContextMenu, + MarkdownFileContextMenuAction, NativeMarkdownTextStyle, SelectableMarkdownSkill, SelectableMarkdownTextProps, diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 6fa61aab1..25f1e94c1 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -198,6 +198,8 @@ @implementation T3MarkdownText { BOOL _suppressSelectionChange; NSMutableDictionary * _attachmentImages; NSMutableSet * _pendingAttachmentUris; + UILongPressGestureRecognizer *_longPressGestureRecognizer; + UITapGestureRecognizer *_pressGestureRecognizer; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -223,21 +225,24 @@ - (instancetype)initWithFrame:(CGRect)frame _textView.textContainerInset = UIEdgeInsetsZero; _textView.textContainer.lineFragmentPadding = 0; _textView.delegate = self; + // Chat text supports selection and contextual actions, but not drag-and-drop. + _textView.textDragInteraction.enabled = NO; + _textView.linkTextAttributes = @{}; // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO. _textView.layoutManager.usesFontLeading = NO; [self addSubview:_textView]; - const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self - action:@selector(handleLongPressIfNecessary:)]; - longPressGestureRecognizer.delegate = self; + _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self + action:@selector(handleLongPressIfNecessary:)]; + _longPressGestureRecognizer.delegate = self; - const auto pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self - action:@selector(handlePressIfNecessary:)]; - pressGestureRecognizer.delegate = self; - [pressGestureRecognizer requireGestureRecognizerToFail:longPressGestureRecognizer]; + _pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(handlePressIfNecessary:)]; + _pressGestureRecognizer.delegate = self; + [_pressGestureRecognizer requireGestureRecognizerToFail:_longPressGestureRecognizer]; - [_textView addGestureRecognizer:pressGestureRecognizer]; - [_textView addGestureRecognizer:longPressGestureRecognizer]; + [_textView addGestureRecognizer:_pressGestureRecognizer]; + [_textView addGestureRecognizer:_longPressGestureRecognizer]; } return self; @@ -312,6 +317,26 @@ - (void)drawRect:(CGRect)rect convertedAttrString, _state->getData().attachmentRanges, _attachmentImages); + NSUInteger runLocation = 0; + for (UIView *child in self.subviews) { + if (![child isKindOfClass:[T3MarkdownTextRun class]]) { + continue; + } + + T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child; + const NSRange runRange = NSMakeRange(runLocation, textChild.text.length); + runLocation = NSMaxRange(runRange); + if (![textChild hasContextMenu] || runRange.length == 0 || + NSMaxRange(runRange) > convertedAttrString.length) { + continue; + } + + NSURL *link = [NSURL URLWithString: + [NSString stringWithFormat:@"t3-markdown-run://%ld", (long)textChild.tag]]; + if (link != nil) { + [convertedAttrString addAttribute:NSLinkAttributeName value:link range:runRange]; + } + } [self loadAttachmentImages:_state->getData().attachmentRanges]; // Setting attributedText clears any active text selection, and re-assigning @@ -484,6 +509,18 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecogni return YES; } +- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer +{ + if (gestureRecognizer != _longPressGestureRecognizer && + gestureRecognizer != _pressGestureRecognizer) { + return YES; + } + + const auto location = [self getLocationOfPress:gestureRecognizer]; + const auto child = [self getTouchChild:location]; + return ![child hasContextMenu]; +} + - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { return YES; @@ -507,6 +544,24 @@ - (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView // MARK: - Touch handling +- (nullable T3MarkdownTextRun *)childForCharacterRange:(NSRange)characterRange +{ + NSUInteger location = 0; + for (UIView *child in self.subviews) { + if (![child isKindOfClass:[T3MarkdownTextRun class]]) { + continue; + } + + T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child; + const NSRange range = NSMakeRange(location, textChild.text.length); + if (NSIntersectionRange(range, characterRange).length > 0) { + return textChild; + } + location = NSMaxRange(range); + } + return nil; +} + - (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender { return [sender locationInView:_textView]; @@ -550,6 +605,10 @@ - (void)handlePressIfNecessary:(UITapGestureRecognizer*)sender - (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender { + if (sender.state != UIGestureRecognizerStateBegan) { + return; + } + const auto location = [self getLocationOfPress:sender]; const auto child = [self getTouchChild:location]; @@ -560,6 +619,30 @@ - (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender // MARK: - UITextViewDelegate +- (nullable UIAction *)textView:(UITextView *)textView + primaryActionForTextItem:(UITextItem *)textItem + defaultAction:(UIAction *)defaultAction API_AVAILABLE(ios(17.0)) +{ + T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range]; + if (![child hasContextMenu]) { + return defaultAction; + } + + __weak T3MarkdownTextRun *weakChild = child; + return [UIAction actionWithHandler:^(__kindof UIAction *action) { + [weakChild onPress]; + }]; +} + +- (nullable UITextItemMenuConfiguration *)textView:(UITextView *)textView + menuConfigurationForTextItem:(UITextItem *)textItem + defaultMenu:(UIMenu *)defaultMenu API_AVAILABLE(ios(17.0)) +{ + T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range]; + UIMenu *menu = [child contextMenu]; + return [UITextItemMenuConfiguration configurationWithMenu:menu ?: defaultMenu]; +} + - (void)textViewDidChangeSelection:(UITextView *)textView { if (_suppressSelectionChange) { diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h index b8b406571..a3b2b4191 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h @@ -13,6 +13,9 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy, nullable) NSString *text; +- (nullable UIMenu *)contextMenu; +- (BOOL)hasContextMenu; +- (void)onContextMenuAction:(NSString *)actionIdentifier; - (void)onPress; - (void)onLongPress; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm index 4549084f0..d2de68843 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm @@ -15,8 +15,7 @@ @interface T3MarkdownTextRun () @implementation T3MarkdownTextRun { NSString * _text; - RCTBubblingEventBlock _onPress; - RCTBubblingEventBlock _onLongPress; + NSString * _contextMenuConfig; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -43,9 +42,78 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _text = text; } + if (newViewProps.contextMenuConfig != oldViewProps.contextMenuConfig) { + _contextMenuConfig = [NSString stringWithUTF8String:newViewProps.contextMenuConfig.c_str()]; + } + [super updateProps:props oldProps:oldProps]; } +- (BOOL)hasContextMenu +{ + return _contextMenuConfig.length > 0; +} + +- (nullable UIMenu *)contextMenu +{ + if (_contextMenuConfig.length == 0) { + return nil; + } + + NSData *data = [_contextMenuConfig dataUsingEncoding:NSUTF8StringEncoding]; + NSDictionary *config = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if (![config isKindOfClass:[NSDictionary class]]) { + return nil; + } + + NSArray *actionConfigs = config[@"actions"]; + if (![actionConfigs isKindOfClass:[NSArray class]] || actionConfigs.count == 0) { + return nil; + } + + NSMutableArray *actions = [NSMutableArray arrayWithCapacity:actionConfigs.count]; + __weak T3MarkdownTextRun *weakSelf = self; + for (NSDictionary *actionConfig in actionConfigs) { + if (![actionConfig isKindOfClass:[NSDictionary class]]) { + continue; + } + NSString *actionIdentifier = actionConfig[@"id"]; + NSString *title = actionConfig[@"title"]; + if (![actionIdentifier isKindOfClass:[NSString class]] || + ![title isKindOfClass:[NSString class]]) { + continue; + } + + UIAction *action = [UIAction actionWithTitle:title + image:nil + identifier:actionIdentifier + handler:^(__kindof UIAction *selectedAction) { + [weakSelf onContextMenuAction:selectedAction.identifier]; + }]; + if ([actionConfig[@"disabled"] boolValue]) { + action.attributes = UIMenuElementAttributesDisabled; + } + [actions addObject:action]; + } + + if (actions.count == 0) { + return nil; + } + NSString *title = [config[@"title"] isKindOfClass:[NSString class]] ? config[@"title"] : @""; + return [UIMenu menuWithTitle:title children:actions]; +} + +- (void)onContextMenuAction:(NSString *)actionIdentifier +{ + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onContextMenuAction(facebook::react::T3MarkdownTextRunEventEmitter::OnContextMenuAction{ + static_cast(self.tag), + actionIdentifier.UTF8String, + }); + } +} + - (void)onPress { if (_eventEmitter != nullptr) { std::dynamic_pointer_cast(_eventEmitter) diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx index 6ed7fecd2..2cd54b5c1 100644 --- a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx @@ -24,8 +24,14 @@ export type SelectionChangeEvent = { nativeEvent: { target: number; start: number; end: number }; }; +export type ContextMenuActionEvent = { + nativeEvent: { target: number; actionIdentifier: string }; +}; + export type MarkdownTextPrimitiveProps = TextProps & { uiTextView?: boolean; + contextMenuConfig?: string; + onContextMenuAction?: (event: ContextMenuActionEvent) => void; /** * Fired when the native text selection changes. Only fires on iOS when * `uiTextView` is true. Note: fires on every selection-edge adjustment diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx index 994c8ce2e..ea4bd0f24 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx @@ -1,9 +1,23 @@ +import { createContext, useContext } from "react"; import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; import { markdownFileIconSource } from "./markdownFileIcons"; import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; -import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; +import type { + MarkdownFileContextMenu, + NativeMarkdownTextStyle, +} from "./SelectableMarkdownText.types"; + +export interface MarkdownFileContextMenuHandlers { + readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; + readonly onFileContextMenuAction: (href: string, actionId: string) => void; +} + +/** Set by SelectableMarkdownText so file chips anywhere in the block tree get the same menu. */ +export const MarkdownFileContextMenuContext = createContext( + null, +); const EXTERNAL_LINK_PREFIX = "◉ "; const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; @@ -139,6 +153,7 @@ export function NativeMarkdownSelectableText(props: { readonly onLinkPress?: (href: string) => void; }) { const colorScheme = useColorScheme(); + const menu = useContext(MarkdownFileContextMenuContext); const occurrences = new Map(); const prefixedExternalLinks = new Set(); const keyedRuns = props.runs.map((run) => { @@ -195,6 +210,7 @@ export function NativeMarkdownSelectableText(props: { > {keyedRuns.map(({ key, run, text }) => { const href = run.href; + const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; return ( menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier) + : undefined + } > {text} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx index 188a45e07..2a231c603 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -9,7 +9,11 @@ import { nativeMarkdownWithPreservedSoftBreaks, } from "./nativeMarkdownText"; import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; -import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; +import { + MarkdownFileContextMenuContext, + NativeMarkdownSelectableText, + type MarkdownFileContextMenuHandlers, +} from "./NativeMarkdownSelectableText.ios"; import type { SelectableMarkdownSkill, SelectableMarkdownTextProps, @@ -38,6 +42,8 @@ export function SelectableMarkdownText({ highlightCode, preserveSoftBreaks = false, onLinkPress, + fileContextMenu, + onFileContextMenuAction, renderImage, marginTop = 0, marginBottom = 0, @@ -61,41 +67,51 @@ export function SelectableMarkdownText({ ); }, [markdown, preserveSoftBreaks, skills]); + const fileContextMenuHandlers = useMemo( + () => + fileContextMenu && onFileContextMenuAction + ? { fileContextMenu, onFileContextMenuAction } + : null, + [fileContextMenu, onFileContextMenuAction], + ); + return ( - {/* A percentage width here creates a cyclic intrinsic measurement inside + + {/* A percentage width here creates a cyclic intrinsic measurement inside shrink-to-fit containers such as user-message bubbles. Yoga then gives the native text node an unbounded second pass and the parent only clips the resulting single-line width instead of reflowing it. */} - - {chunks.map((chunk, index) => { - const content = - chunk.kind === "rich" ? ( - - ) : ( - - ); + + {chunks.map((chunk, index) => { + const content = + chunk.kind === "rich" ? ( + + ) : ( + + ); - return ( - - {content} - - ); - })} - + return ( + + {content} + + ); + })} + + ); } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index 00260b0c4..50b1cccb6 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -50,6 +50,17 @@ export interface MarkdownImageRequest { */ export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode; +export interface MarkdownFileContextMenuAction { + readonly id: string; + readonly title: string; + readonly disabled?: boolean; +} + +export interface MarkdownFileContextMenu { + readonly title?: string; + readonly actions: ReadonlyArray; +} + export interface SelectableMarkdownTextProps { readonly markdown: string; readonly textStyle: NativeMarkdownTextStyle; @@ -57,6 +68,8 @@ export interface SelectableMarkdownTextProps { readonly skills?: ReadonlyArray; readonly preserveSoftBreaks?: boolean; readonly onLinkPress?: (href: string) => void; + readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined; + readonly onFileContextMenuAction?: (href: string, actionId: string) => void; readonly renderImage?: MarkdownImageRenderer; readonly marginTop?: number; readonly marginBottom?: number; diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts index 7f8fab8d8..040e44bc1 100644 --- a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts @@ -11,6 +11,10 @@ interface TargetedEvent { target: Int32; } +interface ContextMenuActionEvent extends TargetedEvent { + actionIdentifier: string; +} + type TextDecorationLine = "none" | "underline" | "line-through"; type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed"; @@ -42,8 +46,10 @@ interface NativeProps extends ViewProps { textDecorationColor?: ColorValue; textAlign?: WithDefault; shadowRadius?: WithDefault; + contextMenuConfig?: string; onPress?: BubblingEventHandler; onLongPress?: BubblingEventHandler; + onContextMenuAction?: BubblingEventHandler; } export default codegenNativeComponent("T3MarkdownTextRun", { diff --git a/apps/mobile/modules/t3-terminal/README.md b/apps/mobile/modules/t3-terminal/README.md index 32670b893..51586a540 100644 --- a/apps/mobile/modules/t3-terminal/README.md +++ b/apps/mobile/modules/t3-terminal/README.md @@ -8,9 +8,9 @@ The JavaScript contract is intentionally small: - resize from the native surface is emitted as `{ cols: number, rows: number }` - remote PTY output is delivered by the existing `WsRpcClient.terminal` RPC stream -The iOS implementation uses the vendored `GhosttyKit.xcframework` built from the Ghostty custom-I/O -fork, with T3's iOS 16 compatibility patch applied. `T3TerminalView` owns a `libghostty` surface and -uses that callback I/O model: +The iOS implementation uses the vendored `GhosttyKit.xcframework` built from VVTerm's Ghostty +custom-I/O and live-padding branch. `T3TerminalView` owns a `libghostty` surface and uses that +callback I/O model: 1. initialize libghostty once for the process 2. create one Ghostty app and surface per native view @@ -26,14 +26,17 @@ Vendored Ghostty revision and license details are in `THIRD_PARTY_NOTICES.md`. ## Rebuilding GhosttyKit -The checked-in `GhosttyKit.xcframework` is built from the Ghostty custom-I/O fork (https://github.com/Yash-Singh1/ghostty/tree/custom-io). -Set the directory to the cloned repository checked out on the `custom-io` branch to `GHOSTTY_SOURCE_DIR`. +The checked-in `GhosttyKit.xcframework` is built from Yash Singh's Ghostty fork at revision +`cf8edc23f3a6a87a96e41a90013e89e987d34980`. Set `GHOSTTY_SOURCE_DIR` to a clone of +https://github.com/Yash-Singh1/ghostty checked out at that revision (the +`t3code/custom-io-ordered-feed` branch when vendored, based on VVTerm's +`vvterm/custom-io-padding` branch). ```bash apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh ``` -The script builds Ghostty with Zig 0.15.2, strips the iOS archives, and replaces only the +The script builds Ghostty with Zig 0.16.0, strips the iOS archives, and replaces only the `ios-arm64` and `ios-arm64-simulator` slices. Xcode's Metal toolchain must be installed; if `metal` fails, run `xcodebuild -downloadComponent MetalToolchain`. diff --git a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md index b06f18ead..0f8a18d50 100644 --- a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md +++ b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md @@ -2,13 +2,13 @@ ## Ghostty / libghostty -The iOS terminal renderer vendors `GhosttyKit.xcframework`, a libghostty build produced from T3's -iOS 16 support fork. That fork was created from VVTerm's custom-I/O Ghostty fork. +The iOS terminal renderer vendors `GhosttyKit.xcframework`, a libghostty build produced from +VVTerm's custom-I/O and live-padding Ghostty branch. - Upstream project: https://github.com/ghostty-org/ghostty -- Custom-I/O base fork: https://github.com/wiedymi/ghostty/tree/custom-io -- Vendored source fork: https://github.com/Yash-Singh1/ghostty/tree/custom-io -- Vendored revision: `d36c3b8dffd0d756dd5e5f4933962f774a0e6753` +- Vendored source branch: https://github.com/Yash-Singh1/ghostty/tree/t3code/custom-io-ordered-feed +- Vendored revision: `cf8edc23f3a6a87a96e41a90013e89e987d34980` +- Based on: https://github.com/wiedymi/ghostty/tree/vvterm/custom-io-padding - Reference integration: https://github.com/vivy-company/vvterm - License: MIT diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty.h index 232e094ce..05ee8f182 100644 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty.h +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty.h @@ -1,10 +1,14 @@ -// Ghostty embedding API. The documentation for the embedding API is -// only within the Zig source files that define the implementations. This -// isn't meant to be a general purpose embedding API (yet) so there hasn't -// been documentation or example work beyond that. +// Ghostty's internal embedder API, a.k.a. "libghostty-internal". // -// The only consumer of this API is the macOS app, but the API is built to -// be more general purpose. +// The only consumer of this API is the macOS app, and while it is fairly +// comprehensive, it is tailored to the needs of the macOS app and not designed +// for external use, hence why most functions are undocumented and some are +// macOS-specific (e.g. ones dealing with the Metal graphics API). +// +// External embedders should instead use `libghostty-vt` or other related +// packages, which are extensively documented and designed from the ground up +// to be used in other software. Header files for which can be found in +// `include/ghostty/`. #ifndef GHOSTTY_H #define GHOSTTY_H @@ -68,7 +72,7 @@ typedef enum { GHOSTTY_PLATFORM_IOS, } ghostty_platform_e; -// Callback for custom I/O write handler. +// Callback for custom surface I/O writes. typedef void (*ghostty_surface_write_fn)(void* userdata, const uint8_t* data, size_t len); @@ -76,19 +80,56 @@ typedef void (*ghostty_surface_write_fn)(void* userdata, typedef enum { GHOSTTY_CLIPBOARD_STANDARD, GHOSTTY_CLIPBOARD_SELECTION, + GHOSTTY_CLIPBOARD_PRIMARY, } ghostty_clipboard_e; +// One representation of clipboard contents. The data is binary-safe with +// an explicit length; it is not necessarily null-terminated. typedef struct { const char *mime; const char *data; + size_t len; } ghostty_clipboard_content_s; +// The payload for completing a clipboard read request. See +// ghostty_surface_complete_clipboard_request. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + bool confirmed; + bool remember; +} ghostty_clipboard_complete_s; + +// The payload of a clipboard read confirmation request: the would-be +// completion contents plus the information shown in the permission +// prompt. See ghostty_runtime_confirm_read_clipboard_cb. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + const char *name; + bool can_remember; +} ghostty_clipboard_confirm_s; + typedef enum { GHOSTTY_CLIPBOARD_REQUEST_PASTE, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, + GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ, + GHOSTTY_CLIPBOARD_REQUEST_KITTY_WRITE, + GHOSTTY_CLIPBOARD_REQUEST_LIST, } ghostty_clipboard_request_e; +// apprt.ClipboardReadResult +typedef enum { + GHOSTTY_CLIPBOARD_READ_STARTED, + GHOSTTY_CLIPBOARD_READ_UNAVAILABLE, + GHOSTTY_CLIPBOARD_READ_UNSUPPORTED, +} ghostty_clipboard_read_result_e; + typedef enum { GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_PRESS, @@ -369,7 +410,6 @@ typedef enum { } ghostty_input_trigger_tag_e; typedef union { - ghostty_input_key_e translated; ghostty_input_key_e physical; uint32_t unicode; // catch_all has no payload @@ -652,6 +692,12 @@ typedef enum { GHOSTTY_INSPECTOR_HIDE, } ghostty_action_inspector_e; +// apprt.action.ExportTerminalIO.C +typedef struct { + const char* contents; + size_t len; +} ghostty_action_export_terminal_io_s; + // apprt.action.QuitTimer typedef enum { GHOSTTY_QUIT_TIMER_START, @@ -679,6 +725,7 @@ typedef struct { typedef enum { GHOSTTY_PROMPT_TITLE_SURFACE, GHOSTTY_PROMPT_TITLE_TAB, + GHOSTTY_PROMPT_TITLE_WINDOW, } ghostty_action_prompt_title_e; // apprt.action.Pwd.C @@ -686,6 +733,14 @@ typedef struct { const char* pwd; } ghostty_action_pwd_s; +// apprt.action.OpenConfig +typedef enum { + // Open the config in the OS default editor. + GHOSTTY_ACTION_OPEN_CONFIG_OS_OPEN, + // Open the config in a new window using $EDITOR or $VISUAL + GHOSTTY_ACTION_OPEN_CONFIG_NEW_WINDOW, +} ghostty_action_open_config_e; + // terminal.MouseShape typedef enum { GHOSTTY_MOUSE_SHAPE_DEFAULT, @@ -819,6 +874,7 @@ typedef enum { GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN, GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, GHOSTTY_ACTION_OPEN_URL_KIND_HTML, + GHOSTTY_ACTION_OPEN_URL_KIND_OSC8, } ghostty_action_open_url_kind_e; // apprt.action.OpenUrl.C @@ -921,9 +977,11 @@ typedef enum { GHOSTTY_ACTION_INSPECTOR, GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, GHOSTTY_ACTION_RENDER_INSPECTOR, + GHOSTTY_ACTION_EXPORT_TERMINAL_IO, GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, GHOSTTY_ACTION_SET_TAB_TITLE, + GHOSTTY_ACTION_SET_WINDOW_TITLE, GHOSTTY_ACTION_PROMPT_TITLE, GHOSTTY_ACTION_PWD, GHOSTTY_ACTION_MOUSE_SHAPE, @@ -941,6 +999,7 @@ typedef enum { GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, GHOSTTY_ACTION_RING_BELL, + GHOSTTY_ACTION_SELECTION_CHANGED, GHOSTTY_ACTION_UNDO, GHOSTTY_ACTION_REDO, GHOSTTY_ACTION_CHECK_FOR_UPDATES, @@ -955,6 +1014,7 @@ typedef enum { GHOSTTY_ACTION_SEARCH_SELECTED, GHOSTTY_ACTION_READONLY, GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD, + GHOSTTY_ACTION_MOVE_TAB_TO_NEW_WINDOW, } ghostty_action_tag_e; typedef union { @@ -970,6 +1030,7 @@ typedef union { ghostty_action_cell_size_s cell_size; ghostty_action_scrollbar_s scrollbar; ghostty_action_inspector_e inspector; + ghostty_action_export_terminal_io_s export_terminal_io; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; ghostty_action_set_title_s set_tab_title; @@ -996,6 +1057,7 @@ typedef union { ghostty_action_search_total_s search_total; ghostty_action_search_selected_s search_selected; ghostty_action_readonly_e readonly; + ghostty_action_open_config_e open_config; } ghostty_action_u; typedef struct { @@ -1004,12 +1066,16 @@ typedef struct { } ghostty_action_s; typedef void (*ghostty_runtime_wakeup_cb)(void*); -typedef bool (*ghostty_runtime_read_clipboard_cb)(void*, - ghostty_clipboard_e, - void*); +typedef ghostty_clipboard_read_result_e (*ghostty_runtime_read_clipboard_cb)( + void*, + ghostty_clipboard_e, + void*, + const char* const*, + size_t, + bool); typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( void*, - const char*, + const ghostty_clipboard_confirm_s*, void*, ghostty_clipboard_request_e); typedef void (*ghostty_runtime_write_clipboard_cb)(void*, @@ -1061,6 +1127,8 @@ typedef union { // apprt.ipc.Action.Key typedef enum { GHOSTTY_IPC_ACTION_NEW_WINDOW, + GHOSTTY_IPC_ACTION_NEW_TAB, + GHOSTTY_IPC_ACTION_TOGGLE_QUICK_TERMINAL, } ghostty_ipc_action_tag_e; //------------------------------------------------------------------- @@ -1084,6 +1152,7 @@ GHOSTTY_API bool ghostty_config_get(ghostty_config_t, void*, const char*, uintpt GHOSTTY_API ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, const char*, uintptr_t); +GHOSTTY_API bool ghostty_config_key_is_binding(ghostty_config_t, ghostty_input_key_s); GHOSTTY_API uint32_t ghostty_config_diagnostics_count(ghostty_config_t); GHOSTTY_API ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t); GHOSTTY_API ghostty_string_s ghostty_config_open_path(void); @@ -1095,7 +1164,6 @@ GHOSTTY_API void ghostty_app_tick(ghostty_app_t); GHOSTTY_API void* ghostty_app_userdata(ghostty_app_t); GHOSTTY_API void ghostty_app_set_focus(ghostty_app_t, bool); GHOSTTY_API bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); -GHOSTTY_API bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s); GHOSTTY_API void ghostty_app_keyboard_changed(ghostty_app_t); GHOSTTY_API void ghostty_app_open_config(ghostty_app_t); GHOSTTY_API void ghostty_app_update_config(ghostty_app_t, ghostty_config_t); @@ -1116,10 +1184,12 @@ GHOSTTY_API bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_process_exited(ghostty_surface_t); GHOSTTY_API void ghostty_surface_refresh(ghostty_surface_t); GHOSTTY_API void ghostty_surface_draw(ghostty_surface_t); -GHOSTTY_API void ghostty_surface_feed_data(ghostty_surface_t, const uint8_t*, size_t); +GHOSTTY_API void ghostty_surface_feed_data(ghostty_surface_t, + const uint8_t*, + size_t); GHOSTTY_API void ghostty_surface_set_write_callback(ghostty_surface_t, - ghostty_surface_write_fn, - void*); + ghostty_surface_write_fn, + void*); GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool); GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool); @@ -1161,10 +1231,12 @@ GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t, uint16_t); GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); -GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t, - const char*, - void*, - bool); +GHOSTTY_API void ghostty_surface_complete_clipboard_request( + ghostty_surface_t, + const ghostty_clipboard_complete_s*, + void*); +GHOSTTY_API void ghostty_surface_deny_clipboard_request(ghostty_surface_t, + void*); GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t, diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt.h deleted file mode 100644 index 4f8fef88e..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt.h +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @file vt.h - * - * libghostty-vt - Virtual terminal emulator library - * - * This library provides functionality for parsing and handling terminal - * escape sequences as well as maintaining terminal state such as styles, - * cursor position, screen, scrollback, and more. - * - * WARNING: This is an incomplete, work-in-progress API. It is not yet - * stable and is definitely going to change. - */ - -/** - * @mainpage libghostty-vt - Virtual Terminal Emulator Library - * - * libghostty-vt is a C library which implements a modern terminal emulator, - * extracted from the [Ghostty](https://ghostty.org) terminal emulator. - * - * libghostty-vt contains the logic for handling the core parts of a terminal - * emulator: parsing terminal escape sequences, maintaining terminal state, - * encoding input events, etc. It can handle scrollback, line wrapping, - * reflow on resize, and more. - * - * @warning This library is currently in development and the API is not yet stable. - * Breaking changes are expected in future versions. Use with caution in production code. - * - * @section groups_sec API Reference - * - * The API is organized into the following groups: - * - @ref key "Key Encoding" - Encode key events into terminal sequences - * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences - * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences - * - @ref paste "Paste Utilities" - Validate paste data safety - * - @ref allocator "Memory Management" - Memory management and custom allocators - * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions - * - * @section examples_sec Examples - * - * Complete working examples: - * - @ref c-vt/src/main.c - OSC parser example - * - @ref c-vt-key-encode/src/main.c - Key encoding example - * - @ref c-vt-paste/src/main.c - Paste safety check example - * - @ref c-vt-sgr/src/main.c - SGR parser example - * - */ - -/** @example c-vt/src/main.c - * This example demonstrates how to use the OSC parser to parse an OSC sequence, - * extract command information, and retrieve command-specific data like window titles. - */ - -/** @example c-vt-key-encode/src/main.c - * This example demonstrates how to use the key encoder to convert key events - * into terminal escape sequences using the Kitty keyboard protocol. - */ - -/** @example c-vt-paste/src/main.c - * This example demonstrates how to use the paste utilities to check if - * paste data is safe before sending it to the terminal. - */ - -/** @example c-vt-sgr/src/main.c - * This example demonstrates how to use the SGR parser to parse terminal - * styling sequences and extract text attributes like colors and underline styles. - */ - -#ifndef GHOSTTY_VT_H -#define GHOSTTY_VT_H - -#ifdef __cplusplus -extern "C" { -#endif - -#include -#include -#include -#include -#include -#include -#include - -#ifdef __cplusplus -} -#endif - -#endif /* GHOSTTY_VT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/allocator.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/allocator.h deleted file mode 100644 index 4cebe91bb..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/allocator.h +++ /dev/null @@ -1,196 +0,0 @@ -/** - * @file allocator.h - * - * Memory management interface for libghostty-vt. - */ - -#ifndef GHOSTTY_VT_ALLOCATOR_H -#define GHOSTTY_VT_ALLOCATOR_H - -#include -#include -#include - -/** @defgroup allocator Memory Management - * - * libghostty-vt does require memory allocation for various operations, - * but is resilient to allocation failures and will gracefully handle - * out-of-memory situations by returning error codes. - * - * The exact memory management semantics are documented in the relevant - * functions and data structures. - * - * libghostty-vt uses explicit memory allocation via an allocator - * interface provided by GhosttyAllocator. The interface is based on the - * [Zig](https://ziglang.org) allocator interface, since this has been - * shown to be a flexible and powerful interface in practice and enables - * a wide variety of allocation strategies. - * - * **For the common case, you can pass NULL as the allocator for any - * function that accepts one,** and libghostty will use a default allocator. - * The default allocator will be libc malloc/free if libc is linked. - * Otherwise, a custom allocator is used (currently Zig's SMP allocator) - * that doesn't require any external dependencies. - * - * ## Basic Usage - * - * For simple use cases, you can ignore this interface entirely by passing NULL - * as the allocator parameter to functions that accept one. This will use the - * default allocator (typically libc malloc/free, if libc is linked, but - * we provide our own default allocator if libc isn't linked). - * - * To use a custom allocator: - * 1. Implement the GhosttyAllocatorVtable function pointers - * 2. Create a GhosttyAllocator struct with your vtable and context - * 3. Pass the allocator to functions that accept one - * - * @{ - */ - -/** - * Function table for custom memory allocator operations. - * - * This vtable defines the interface for a custom memory allocator. All - * function pointers must be valid and non-NULL. - * - * @ingroup allocator - * - * If you're not going to use a custom allocator, you can ignore all of - * this. All functions that take an allocator pointer allow NULL to use a - * default allocator. - * - * The interface is based on the Zig allocator interface. I'll say up front - * that it is easy to look at this interface and think "wow, this is really - * overcomplicated". The reason for this complexity is well thought out by - * the Zig folks, and it enables a diverse set of allocation strategies - * as shown by the Zig ecosystem. As a consolation, please note that many - * of the arguments are only needed for advanced use cases and can be - * safely ignored in simple implementations. For example, if you look at - * the Zig implementation of the libc allocator in `lib/std/heap.zig` - * (search for CAllocator), you'll see it is very simple. - * - * We chose to align with the Zig allocator interface because: - * - * 1. It is a proven interface that serves a wide variety of use cases - * in the real world via the Zig ecosystem. It's shown to work. - * - * 2. Our core implementation itself is Zig, and this lets us very - * cheaply and easily convert between C and Zig allocators. - * - * NOTE(mitchellh): In the future, we can have default implementations of - * resize/remap and allow those to be null. - */ -typedef struct { - /** - * Return a pointer to `len` bytes with specified `alignment`, or return - * `NULL` indicating the allocation failed. - * - * @param ctx The allocator context - * @param len Number of bytes to allocate - * @param alignment Required alignment for the allocation. Guaranteed to - * be a power of two between 1 and 16 inclusive. - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return Pointer to allocated memory, or NULL if allocation failed - */ - void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr); - - /** - * Attempt to expand or shrink memory in place. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * `new_len` must be greater than zero. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to resize - * @param memory_len Current size of the memory block - * @param alignment Alignment (must match original allocation) - * @param new_len New requested size - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return true if resize was successful in-place, false if relocation would be required - */ - bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); - - /** - * Attempt to expand or shrink memory, allowing relocation. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * A non-`NULL` return value indicates the resize was successful. The - * allocation may have same address, or may have been relocated. In either - * case, the allocation now has size of `new_len`. A `NULL` return value - * indicates that the resize would be equivalent to allocating new memory, - * copying the bytes from the old memory, and then freeing the old memory. - * In such case, it is more efficient for the caller to perform the copy. - * - * `new_len` must be greater than zero. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to remap - * @param memory_len Current size of the memory block - * @param alignment Alignment (must match original allocation) - * @param new_len New requested size - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed - */ - void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); - - /** - * Free and invalidate a region of memory. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to free - * @param memory_len Size of the memory block - * @param alignment Alignment (must match original allocation) - * @param ret_addr First return address of the allocation call stack (0 if not provided) - */ - void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr); -} GhosttyAllocatorVtable; - -/** - * Custom memory allocator. - * - * For functions that take an allocator pointer, a NULL pointer indicates - * that the default allocator should be used. The default allocator will - * be libc malloc/free if we're linking to libc. If libc isn't linked, - * a custom allocator is used (currently Zig's SMP allocator). - * - * @ingroup allocator - * - * Usage example: - * @code - * GhosttyAllocator allocator = { - * .vtable = &my_allocator_vtable, - * .ctx = my_allocator_state - * }; - * @endcode - */ -typedef struct GhosttyAllocator { - /** - * Opaque context pointer passed to all vtable functions. - * This allows the allocator implementation to maintain state - * or reference external resources needed for memory management. - */ - void *ctx; - - /** - * Pointer to the allocator's vtable containing function pointers - * for memory operations (alloc, resize, remap, free). - */ - const GhosttyAllocatorVtable *vtable; -} GhosttyAllocator; - -/** @} */ - -#endif /* GHOSTTY_VT_ALLOCATOR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/color.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/color.h deleted file mode 100644 index 0d57b8db4..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/color.h +++ /dev/null @@ -1,96 +0,0 @@ -/** - * @file color.h - * - * Color types and utilities. - */ - -#ifndef GHOSTTY_VT_COLOR_H -#define GHOSTTY_VT_COLOR_H - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * RGB color value. - * - * @ingroup sgr - */ -typedef struct { - uint8_t r; /**< Red component (0-255) */ - uint8_t g; /**< Green component (0-255) */ - uint8_t b; /**< Blue component (0-255) */ -} GhosttyColorRgb; - -/** - * Palette color index (0-255). - * - * @ingroup sgr - */ -typedef uint8_t GhosttyColorPaletteIndex; - -/** @addtogroup sgr - * @{ - */ - -/** Black color (0) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BLACK 0 -/** Red color (1) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_RED 1 -/** Green color (2) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_GREEN 2 -/** Yellow color (3) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_YELLOW 3 -/** Blue color (4) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BLUE 4 -/** Magenta color (5) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_MAGENTA 5 -/** Cyan color (6) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_CYAN 6 -/** White color (7) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_WHITE 7 -/** Bright black color (8) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8 -/** Bright red color (9) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9 -/** Bright green color (10) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10 -/** Bright yellow color (11) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11 -/** Bright blue color (12) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12 -/** Bright magenta color (13) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13 -/** Bright cyan color (14) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14 -/** Bright white color (15) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15 - -/** @} */ - -/** - * Get the RGB color components. - * - * This function extracts the individual red, green, and blue components - * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments - * where accessing struct fields directly is difficult. - * - * @param color The RGB color value - * @param r Pointer to store the red component (0-255) - * @param g Pointer to store the green component (0-255) - * @param b Pointer to store the blue component (0-255) - * - * @ingroup sgr - */ -void ghostty_color_rgb_get(GhosttyColorRgb color, - uint8_t* r, - uint8_t* g, - uint8_t* b); - -#ifdef __cplusplus -} -#endif - -#endif /* GHOSTTY_VT_COLOR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key.h deleted file mode 100644 index 772b5d43b..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key.h +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @file key.h - * - * Key encoding module - encode key events into terminal escape sequences. - */ - -#ifndef GHOSTTY_VT_KEY_H -#define GHOSTTY_VT_KEY_H - -/** @defgroup key Key Encoding - * - * Utilities for encoding key events into terminal escape sequences, - * supporting both legacy encoding as well as Kitty Keyboard Protocol. - * - * ## Basic Usage - * - * 1. Create an encoder instance with ghostty_key_encoder_new() - * 2. Configure encoder options with ghostty_key_encoder_setopt(). - * 3. For each key event: - * - Create a key event with ghostty_key_event_new() - * - Set event properties (action, key, modifiers, etc.) - * - Encode with ghostty_key_encoder_encode() - * - Free the event with ghostty_key_event_free() - * - Note: You can also reuse the same key event multiple times by - * changing its properties. - * 4. Free the encoder with ghostty_key_encoder_free() when done - * - * ## Example - * - * @code{.c} - * #include - * #include - * #include - * - * int main() { - * // Create encoder - * GhosttyKeyEncoder encoder; - * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); - * assert(result == GHOSTTY_SUCCESS); - * - * // Enable Kitty keyboard protocol with all features - * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, - * &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); - * - * // Create and configure key event for Ctrl+C press - * GhosttyKeyEvent event; - * result = ghostty_key_event_new(NULL, &event); - * assert(result == GHOSTTY_SUCCESS); - * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS); - * ghostty_key_event_set_key(event, GHOSTTY_KEY_C); - * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); - * - * // Encode the key event - * char buf[128]; - * size_t written = 0; - * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); - * assert(result == GHOSTTY_SUCCESS); - * - * // Use the encoded sequence (e.g., write to terminal) - * fwrite(buf, 1, written, stdout); - * - * // Cleanup - * ghostty_key_event_free(event); - * ghostty_key_encoder_free(encoder); - * return 0; - * } - * @endcode - * - * For a complete working example, see example/c-vt-key-encode in the - * repository. - * - * @{ - */ - -#include -#include - -/** @} */ - -#endif /* GHOSTTY_VT_KEY_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/encoder.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/encoder.h deleted file mode 100644 index 766a29427..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/encoder.h +++ /dev/null @@ -1,221 +0,0 @@ -/** - * @file encoder.h - * - * Key event encoding to terminal escape sequences. - */ - -#ifndef GHOSTTY_VT_KEY_ENCODER_H -#define GHOSTTY_VT_KEY_ENCODER_H - -#include -#include -#include -#include -#include - -/** - * Opaque handle to a key encoder instance. - * - * This handle represents a key encoder that converts key events into terminal - * escape sequences. - * - * @ingroup key - */ -typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder; - -/** - * Kitty keyboard protocol flags. - * - * Bitflags representing the various modes of the Kitty keyboard protocol. - * These can be combined using bitwise OR operations. Valid values all - * start with `GHOSTTY_KITTY_KEY_`. - * - * @ingroup key - */ -typedef uint8_t GhosttyKittyKeyFlags; - -/** Kitty keyboard protocol disabled (all flags off) */ -#define GHOSTTY_KITTY_KEY_DISABLED 0 - -/** Disambiguate escape codes */ -#define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0) - -/** Report key press and release events */ -#define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1) - -/** Report alternate key codes */ -#define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2) - -/** Report all key events including those normally handled by the terminal */ -#define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3) - -/** Report associated text with key events */ -#define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4) - -/** All Kitty keyboard protocol flags enabled */ -#define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED) - -/** - * macOS option key behavior. - * - * Determines whether the "option" key on macOS is treated as "alt" or not. - * See the Ghostty `macos-option-as-alt` configuration option for more details. - * - * @ingroup key - */ -typedef enum { - /** Option key is not treated as alt */ - GHOSTTY_OPTION_AS_ALT_FALSE = 0, - /** Option key is treated as alt */ - GHOSTTY_OPTION_AS_ALT_TRUE = 1, - /** Only left option key is treated as alt */ - GHOSTTY_OPTION_AS_ALT_LEFT = 2, - /** Only right option key is treated as alt */ - GHOSTTY_OPTION_AS_ALT_RIGHT = 3, -} GhosttyOptionAsAlt; - -/** - * Key encoder option identifiers. - * - * These values are used with ghostty_key_encoder_setopt() to configure - * the behavior of the key encoder. - * - * @ingroup key - */ -typedef enum { - /** Terminal DEC mode 1: cursor key application mode (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0, - - /** Terminal DEC mode 66: keypad key application mode (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1, - - /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2, - - /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3, - - /** xterm modifyOtherKeys mode 2 (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4, - - /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */ - GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5, - - /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */ - GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6, -} GhosttyKeyEncoderOption; - -/** - * Create a new key encoder instance. - * - * Creates a new key encoder with default options. The encoder can be configured - * using ghostty_key_encoder_setopt() and must be freed using - * ghostty_key_encoder_free() when no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param encoder Pointer to store the created encoder handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup key - */ -GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder); - -/** - * Free a key encoder instance. - * - * Releases all resources associated with the key encoder. After this call, - * the encoder handle becomes invalid and must not be used. - * - * @param encoder The encoder handle to free (may be NULL) - * - * @ingroup key - */ -void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); - -/** - * Set an option on the key encoder. - * - * Configures the behavior of the key encoder. Options control various aspects - * of encoding such as terminal modes (cursor key application mode, keypad mode), - * protocol selection (Kitty keyboard protocol flags), and platform-specific - * behaviors (macOS option-as-alt). - * - * A null pointer value does nothing. It does not reset the value to the - * default. The setopt call will do nothing. - * - * @param encoder The encoder handle, must not be NULL - * @param option The option to set - * @param value Pointer to the value to set (type depends on the option) - * - * @ingroup key - */ -void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value); - -/** - * Encode a key event into a terminal escape sequence. - * - * Converts a key event into the appropriate terminal escape sequence based on - * the encoder's current options. The sequence is written to the provided buffer. - * - * Not all key events produce output. For example, unmodified modifier keys - * typically don't generate escape sequences. Check the out_len parameter to - * determine if any data was written. - * - * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY - * and out_len will contain the required buffer size. The caller can then - * allocate a larger buffer and call the function again. - * - * @param encoder The encoder handle, must not be NULL - * @param event The key event to encode, must not be NULL - * @param out_buf Buffer to write the encoded sequence to - * @param out_buf_size Size of the output buffer in bytes - * @param out_len Pointer to store the number of bytes written (may be NULL) - * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code - * - * ## Example: Calculate required buffer size - * - * @code{.c} - * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY) - * size_t required = 0; - * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); - * assert(result == GHOSTTY_OUT_OF_MEMORY); - * - * // Allocate buffer of required size - * char *buf = malloc(required); - * - * // Encode with properly sized buffer - * size_t written = 0; - * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written); - * assert(result == GHOSTTY_SUCCESS); - * - * // Use the encoded sequence... - * - * free(buf); - * @endcode - * - * ## Example: Direct encoding with static buffer - * - * @code{.c} - * // Most escape sequences are short, so a static buffer often suffices - * char buf[128]; - * size_t written = 0; - * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); - * - * if (result == GHOSTTY_SUCCESS) { - * // Write the encoded sequence to the terminal - * write(pty_fd, buf, written); - * } else if (result == GHOSTTY_OUT_OF_MEMORY) { - * // Buffer too small, written contains required size - * char *dynamic_buf = malloc(written); - * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written); - * assert(result == GHOSTTY_SUCCESS); - * write(pty_fd, dynamic_buf, written); - * free(dynamic_buf); - * } - * @endcode - * - * @ingroup key - */ -GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len); - -#endif /* GHOSTTY_VT_KEY_ENCODER_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/event.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/event.h deleted file mode 100644 index dbd2e9f84..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/event.h +++ /dev/null @@ -1,474 +0,0 @@ -/** - * @file event.h - * - * Key event representation and manipulation. - */ - -#ifndef GHOSTTY_VT_KEY_EVENT_H -#define GHOSTTY_VT_KEY_EVENT_H - -#include -#include -#include -#include -#include - -/** - * Opaque handle to a key event. - * - * This handle represents a keyboard input event containing information about - * the physical key pressed, modifiers, and generated text. - * - * @ingroup key - */ -typedef struct GhosttyKeyEvent *GhosttyKeyEvent; - -/** - * Keyboard input event types. - * - * @ingroup key - */ -typedef enum { - /** Key was released */ - GHOSTTY_KEY_ACTION_RELEASE = 0, - /** Key was pressed */ - GHOSTTY_KEY_ACTION_PRESS = 1, - /** Key is being repeated (held down) */ - GHOSTTY_KEY_ACTION_REPEAT = 2, -} GhosttyKeyAction; - -/** - * Keyboard modifier keys bitmask. - * - * A bitmask representing all keyboard modifiers. This tracks which modifier keys - * are pressed and, where supported by the platform, which side (left or right) - * of each modifier is active. - * - * Use the GHOSTTY_MODS_* constants to test and set individual modifiers. - * - * Modifier side bits are only meaningful when the corresponding modifier bit is set. - * Not all platforms support distinguishing between left and right modifier - * keys and Ghostty is built to expect that some platforms may not provide this - * information. - * - * @ingroup key - */ -typedef uint16_t GhosttyMods; - -/** Shift key is pressed */ -#define GHOSTTY_MODS_SHIFT (1 << 0) -/** Control key is pressed */ -#define GHOSTTY_MODS_CTRL (1 << 1) -/** Alt/Option key is pressed */ -#define GHOSTTY_MODS_ALT (1 << 2) -/** Super/Command/Windows key is pressed */ -#define GHOSTTY_MODS_SUPER (1 << 3) -/** Caps Lock is active */ -#define GHOSTTY_MODS_CAPS_LOCK (1 << 4) -/** Num Lock is active */ -#define GHOSTTY_MODS_NUM_LOCK (1 << 5) - -/** - * Right shift is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_SHIFT is set. - */ -#define GHOSTTY_MODS_SHIFT_SIDE (1 << 6) -/** - * Right ctrl is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_CTRL is set. - */ -#define GHOSTTY_MODS_CTRL_SIDE (1 << 7) -/** - * Right alt is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_ALT is set. - */ -#define GHOSTTY_MODS_ALT_SIDE (1 << 8) -/** - * Right super is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_SUPER is set. - */ -#define GHOSTTY_MODS_SUPER_SIDE (1 << 9) - -/** - * Physical key codes. - * - * The set of key codes that Ghostty is aware of. These represent physical keys - * on the keyboard and are layout-independent. For example, the "a" key on a US - * keyboard is the same as the "ф" key on a Russian keyboard, but both will - * report the same key_a value. - * - * Layout-dependent strings are provided separately as UTF-8 text and are produced - * by the platform. These values are based on the W3C UI Events KeyboardEvent code - * standard. See: https://www.w3.org/TR/uievents-code - * - * @ingroup key - */ -typedef enum { - GHOSTTY_KEY_UNIDENTIFIED = 0, - - // Writing System Keys (W3C § 3.1.1) - GHOSTTY_KEY_BACKQUOTE, - GHOSTTY_KEY_BACKSLASH, - GHOSTTY_KEY_BRACKET_LEFT, - GHOSTTY_KEY_BRACKET_RIGHT, - GHOSTTY_KEY_COMMA, - GHOSTTY_KEY_DIGIT_0, - GHOSTTY_KEY_DIGIT_1, - GHOSTTY_KEY_DIGIT_2, - GHOSTTY_KEY_DIGIT_3, - GHOSTTY_KEY_DIGIT_4, - GHOSTTY_KEY_DIGIT_5, - GHOSTTY_KEY_DIGIT_6, - GHOSTTY_KEY_DIGIT_7, - GHOSTTY_KEY_DIGIT_8, - GHOSTTY_KEY_DIGIT_9, - GHOSTTY_KEY_EQUAL, - GHOSTTY_KEY_INTL_BACKSLASH, - GHOSTTY_KEY_INTL_RO, - GHOSTTY_KEY_INTL_YEN, - GHOSTTY_KEY_A, - GHOSTTY_KEY_B, - GHOSTTY_KEY_C, - GHOSTTY_KEY_D, - GHOSTTY_KEY_E, - GHOSTTY_KEY_F, - GHOSTTY_KEY_G, - GHOSTTY_KEY_H, - GHOSTTY_KEY_I, - GHOSTTY_KEY_J, - GHOSTTY_KEY_K, - GHOSTTY_KEY_L, - GHOSTTY_KEY_M, - GHOSTTY_KEY_N, - GHOSTTY_KEY_O, - GHOSTTY_KEY_P, - GHOSTTY_KEY_Q, - GHOSTTY_KEY_R, - GHOSTTY_KEY_S, - GHOSTTY_KEY_T, - GHOSTTY_KEY_U, - GHOSTTY_KEY_V, - GHOSTTY_KEY_W, - GHOSTTY_KEY_X, - GHOSTTY_KEY_Y, - GHOSTTY_KEY_Z, - GHOSTTY_KEY_MINUS, - GHOSTTY_KEY_PERIOD, - GHOSTTY_KEY_QUOTE, - GHOSTTY_KEY_SEMICOLON, - GHOSTTY_KEY_SLASH, - - // Functional Keys (W3C § 3.1.2) - GHOSTTY_KEY_ALT_LEFT, - GHOSTTY_KEY_ALT_RIGHT, - GHOSTTY_KEY_BACKSPACE, - GHOSTTY_KEY_CAPS_LOCK, - GHOSTTY_KEY_CONTEXT_MENU, - GHOSTTY_KEY_CONTROL_LEFT, - GHOSTTY_KEY_CONTROL_RIGHT, - GHOSTTY_KEY_ENTER, - GHOSTTY_KEY_META_LEFT, - GHOSTTY_KEY_META_RIGHT, - GHOSTTY_KEY_SHIFT_LEFT, - GHOSTTY_KEY_SHIFT_RIGHT, - GHOSTTY_KEY_SPACE, - GHOSTTY_KEY_TAB, - GHOSTTY_KEY_CONVERT, - GHOSTTY_KEY_KANA_MODE, - GHOSTTY_KEY_NON_CONVERT, - - // Control Pad Section (W3C § 3.2) - GHOSTTY_KEY_DELETE, - GHOSTTY_KEY_END, - GHOSTTY_KEY_HELP, - GHOSTTY_KEY_HOME, - GHOSTTY_KEY_INSERT, - GHOSTTY_KEY_PAGE_DOWN, - GHOSTTY_KEY_PAGE_UP, - - // Arrow Pad Section (W3C § 3.3) - GHOSTTY_KEY_ARROW_DOWN, - GHOSTTY_KEY_ARROW_LEFT, - GHOSTTY_KEY_ARROW_RIGHT, - GHOSTTY_KEY_ARROW_UP, - - // Numpad Section (W3C § 3.4) - GHOSTTY_KEY_NUM_LOCK, - GHOSTTY_KEY_NUMPAD_0, - GHOSTTY_KEY_NUMPAD_1, - GHOSTTY_KEY_NUMPAD_2, - GHOSTTY_KEY_NUMPAD_3, - GHOSTTY_KEY_NUMPAD_4, - GHOSTTY_KEY_NUMPAD_5, - GHOSTTY_KEY_NUMPAD_6, - GHOSTTY_KEY_NUMPAD_7, - GHOSTTY_KEY_NUMPAD_8, - GHOSTTY_KEY_NUMPAD_9, - GHOSTTY_KEY_NUMPAD_ADD, - GHOSTTY_KEY_NUMPAD_BACKSPACE, - GHOSTTY_KEY_NUMPAD_CLEAR, - GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, - GHOSTTY_KEY_NUMPAD_COMMA, - GHOSTTY_KEY_NUMPAD_DECIMAL, - GHOSTTY_KEY_NUMPAD_DIVIDE, - GHOSTTY_KEY_NUMPAD_ENTER, - GHOSTTY_KEY_NUMPAD_EQUAL, - GHOSTTY_KEY_NUMPAD_MEMORY_ADD, - GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, - GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, - GHOSTTY_KEY_NUMPAD_MEMORY_STORE, - GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, - GHOSTTY_KEY_NUMPAD_MULTIPLY, - GHOSTTY_KEY_NUMPAD_PAREN_LEFT, - GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, - GHOSTTY_KEY_NUMPAD_SUBTRACT, - GHOSTTY_KEY_NUMPAD_SEPARATOR, - GHOSTTY_KEY_NUMPAD_UP, - GHOSTTY_KEY_NUMPAD_DOWN, - GHOSTTY_KEY_NUMPAD_RIGHT, - GHOSTTY_KEY_NUMPAD_LEFT, - GHOSTTY_KEY_NUMPAD_BEGIN, - GHOSTTY_KEY_NUMPAD_HOME, - GHOSTTY_KEY_NUMPAD_END, - GHOSTTY_KEY_NUMPAD_INSERT, - GHOSTTY_KEY_NUMPAD_DELETE, - GHOSTTY_KEY_NUMPAD_PAGE_UP, - GHOSTTY_KEY_NUMPAD_PAGE_DOWN, - - // Function Section (W3C § 3.5) - GHOSTTY_KEY_ESCAPE, - GHOSTTY_KEY_F1, - GHOSTTY_KEY_F2, - GHOSTTY_KEY_F3, - GHOSTTY_KEY_F4, - GHOSTTY_KEY_F5, - GHOSTTY_KEY_F6, - GHOSTTY_KEY_F7, - GHOSTTY_KEY_F8, - GHOSTTY_KEY_F9, - GHOSTTY_KEY_F10, - GHOSTTY_KEY_F11, - GHOSTTY_KEY_F12, - GHOSTTY_KEY_F13, - GHOSTTY_KEY_F14, - GHOSTTY_KEY_F15, - GHOSTTY_KEY_F16, - GHOSTTY_KEY_F17, - GHOSTTY_KEY_F18, - GHOSTTY_KEY_F19, - GHOSTTY_KEY_F20, - GHOSTTY_KEY_F21, - GHOSTTY_KEY_F22, - GHOSTTY_KEY_F23, - GHOSTTY_KEY_F24, - GHOSTTY_KEY_F25, - GHOSTTY_KEY_FN, - GHOSTTY_KEY_FN_LOCK, - GHOSTTY_KEY_PRINT_SCREEN, - GHOSTTY_KEY_SCROLL_LOCK, - GHOSTTY_KEY_PAUSE, - - // Media Keys (W3C § 3.6) - GHOSTTY_KEY_BROWSER_BACK, - GHOSTTY_KEY_BROWSER_FAVORITES, - GHOSTTY_KEY_BROWSER_FORWARD, - GHOSTTY_KEY_BROWSER_HOME, - GHOSTTY_KEY_BROWSER_REFRESH, - GHOSTTY_KEY_BROWSER_SEARCH, - GHOSTTY_KEY_BROWSER_STOP, - GHOSTTY_KEY_EJECT, - GHOSTTY_KEY_LAUNCH_APP_1, - GHOSTTY_KEY_LAUNCH_APP_2, - GHOSTTY_KEY_LAUNCH_MAIL, - GHOSTTY_KEY_MEDIA_PLAY_PAUSE, - GHOSTTY_KEY_MEDIA_SELECT, - GHOSTTY_KEY_MEDIA_STOP, - GHOSTTY_KEY_MEDIA_TRACK_NEXT, - GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, - GHOSTTY_KEY_POWER, - GHOSTTY_KEY_SLEEP, - GHOSTTY_KEY_AUDIO_VOLUME_DOWN, - GHOSTTY_KEY_AUDIO_VOLUME_MUTE, - GHOSTTY_KEY_AUDIO_VOLUME_UP, - GHOSTTY_KEY_WAKE_UP, - - // Legacy, Non-standard, and Special Keys (W3C § 3.7) - GHOSTTY_KEY_COPY, - GHOSTTY_KEY_CUT, - GHOSTTY_KEY_PASTE, -} GhosttyKey; - -/** - * Create a new key event instance. - * - * Creates a new key event with default values. The event must be freed using - * ghostty_key_event_free() when no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param event Pointer to store the created key event handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup key - */ -GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event); - -/** - * Free a key event instance. - * - * Releases all resources associated with the key event. After this call, - * the event handle becomes invalid and must not be used. - * - * @param event The key event handle to free (may be NULL) - * - * @ingroup key - */ -void ghostty_key_event_free(GhosttyKeyEvent event); - -/** - * Set the key action (press, release, repeat). - * - * @param event The key event handle, must not be NULL - * @param action The action to set - * - * @ingroup key - */ -void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action); - -/** - * Get the key action (press, release, repeat). - * - * @param event The key event handle, must not be NULL - * @return The key action - * - * @ingroup key - */ -GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event); - -/** - * Set the physical key code. - * - * @param event The key event handle, must not be NULL - * @param key The physical key code to set - * - * @ingroup key - */ -void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key); - -/** - * Get the physical key code. - * - * @param event The key event handle, must not be NULL - * @return The physical key code - * - * @ingroup key - */ -GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event); - -/** - * Set the modifier keys bitmask. - * - * @param event The key event handle, must not be NULL - * @param mods The modifier keys bitmask to set - * - * @ingroup key - */ -void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods); - -/** - * Get the modifier keys bitmask. - * - * @param event The key event handle, must not be NULL - * @return The modifier keys bitmask - * - * @ingroup key - */ -GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event); - -/** - * Set the consumed modifiers bitmask. - * - * @param event The key event handle, must not be NULL - * @param consumed_mods The consumed modifiers bitmask to set - * - * @ingroup key - */ -void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods); - -/** - * Get the consumed modifiers bitmask. - * - * @param event The key event handle, must not be NULL - * @return The consumed modifiers bitmask - * - * @ingroup key - */ -GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event); - -/** - * Set whether the key event is part of a composition sequence. - * - * @param event The key event handle, must not be NULL - * @param composing Whether the key event is part of a composition sequence - * - * @ingroup key - */ -void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing); - -/** - * Get whether the key event is part of a composition sequence. - * - * @param event The key event handle, must not be NULL - * @return Whether the key event is part of a composition sequence - * - * @ingroup key - */ -bool ghostty_key_event_get_composing(GhosttyKeyEvent event); - -/** - * Set the UTF-8 text generated by the key event. - * - * The key event does NOT take ownership of the text pointer. The caller - * must ensure the string remains valid for the lifetime needed by the event. - * - * @param event The key event handle, must not be NULL - * @param utf8 The UTF-8 text to set (or NULL for empty) - * @param len Length of the UTF-8 text in bytes - * - * @ingroup key - */ -void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len); - -/** - * Get the UTF-8 text generated by the key event. - * - * The returned pointer is valid until the event is freed or the UTF-8 text is modified. - * - * @param event The key event handle, must not be NULL - * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL) - * @return The UTF-8 text (or NULL for empty) - * - * @ingroup key - */ -const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len); - -/** - * Set the unshifted Unicode codepoint. - * - * @param event The key event handle, must not be NULL - * @param codepoint The unshifted Unicode codepoint to set - * - * @ingroup key - */ -void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); - -/** - * Get the unshifted Unicode codepoint. - * - * @param event The key event handle, must not be NULL - * @return The unshifted Unicode codepoint - * - * @ingroup key - */ -uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); - -#endif /* GHOSTTY_VT_KEY_EVENT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/osc.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/osc.h deleted file mode 100644 index f53077ab3..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/osc.h +++ /dev/null @@ -1,233 +0,0 @@ -/** - * @file osc.h - * - * OSC (Operating System Command) sequence parser and command handling. - */ - -#ifndef GHOSTTY_VT_OSC_H -#define GHOSTTY_VT_OSC_H - -#include -#include -#include -#include -#include - -/** - * Opaque handle to an OSC parser instance. - * - * This handle represents an OSC (Operating System Command) parser that can - * be used to parse the contents of OSC sequences. - * - * @ingroup osc - */ -typedef struct GhosttyOscParser *GhosttyOscParser; - -/** - * Opaque handle to a single OSC command. - * - * This handle represents a parsed OSC (Operating System Command) command. - * The command can be queried for its type and associated data. - * - * @ingroup osc - */ -typedef struct GhosttyOscCommand *GhosttyOscCommand; - -/** @defgroup osc OSC Parser - * - * OSC (Operating System Command) sequence parser and command handling. - * - * The parser operates in a streaming fashion, processing input byte-by-byte - * to handle OSC sequences that may arrive in fragments across multiple reads. - * This interface makes it easy to integrate into most environments and avoids - * over-allocating buffers. - * - * ## Basic Usage - * - * 1. Create a parser instance with ghostty_osc_new() - * 2. Feed bytes to the parser using ghostty_osc_next() - * 3. Finalize parsing with ghostty_osc_end() to get the command - * 4. Query command type and extract data using ghostty_osc_command_type() - * and ghostty_osc_command_data() - * 5. Free the parser with ghostty_osc_free() when done - * - * @{ - */ - -/** - * OSC command types. - * - * @ingroup osc - */ -typedef enum { - GHOSTTY_OSC_COMMAND_INVALID = 0, - GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, - GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, - GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT = 3, - GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 4, - GHOSTTY_OSC_COMMAND_REPORT_PWD = 5, - GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 6, - GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 7, - GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 8, - GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 9, - GHOSTTY_OSC_COMMAND_HYPERLINK_START = 10, - GHOSTTY_OSC_COMMAND_HYPERLINK_END = 11, - GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 12, - GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 13, - GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 14, - GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 15, - GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 16, - GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 17, - GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS = 18, - GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE = 19, - GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20, - GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21, - GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22, -} GhosttyOscCommandType; - -/** - * OSC command data types. - * - * These values specify what type of data to extract from an OSC command - * using `ghostty_osc_command_data`. - * - * @ingroup osc - */ -typedef enum { - /** Invalid data type. Never results in any data extraction. */ - GHOSTTY_OSC_DATA_INVALID = 0, - - /** - * Window title string data. - * - * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE - * - * Output type: const char ** (pointer to null-terminated string) - * - * Lifetime: Valid until the next call to any ghostty_osc_* function with - * the same parser instance. Memory is owned by the parser. - */ - GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, -} GhosttyOscCommandData; - -/** - * Create a new OSC parser instance. - * - * Creates a new OSC (Operating System Command) parser using the provided - * allocator. The parser must be freed using ghostty_vt_osc_free() when - * no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param parser Pointer to store the created parser handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup osc - */ -GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser); - -/** - * Free an OSC parser instance. - * - * Releases all resources associated with the OSC parser. After this call, - * the parser handle becomes invalid and must not be used. - * - * @param parser The parser handle to free (may be NULL) - * - * @ingroup osc - */ -void ghostty_osc_free(GhosttyOscParser parser); - -/** - * Reset an OSC parser instance to its initial state. - * - * Resets the parser state, clearing any partially parsed OSC sequences - * and returning the parser to its initial state. This is useful for - * reusing a parser instance or recovering from parse errors. - * - * @param parser The parser handle to reset, must not be null. - * - * @ingroup osc - */ -void ghostty_osc_reset(GhosttyOscParser parser); - -/** - * Parse the next byte in an OSC sequence. - * - * Processes a single byte as part of an OSC sequence. The parser maintains - * internal state to track the progress through the sequence. Call this - * function for each byte in the sequence data. - * - * When finished pumping the parser with bytes, call ghostty_osc_end - * to get the final result. - * - * @param parser The parser handle, must not be null. - * @param byte The next byte to parse - * - * @ingroup osc - */ -void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); - -/** - * Finalize OSC parsing and retrieve the parsed command. - * - * Call this function after feeding all bytes of an OSC sequence to the parser - * using ghostty_osc_next() with the exception of the terminating character - * (ESC or ST). This function finalizes the parsing process and returns the - * parsed OSC command. - * - * The return value is never NULL. Invalid commands will return a command - * with type GHOSTTY_OSC_COMMAND_INVALID. - * - * The terminator parameter specifies the byte that terminated the OSC sequence - * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is - * preserved in the parsed command so that responses can use the same terminator - * format for better compatibility with the calling program. For commands that - * do not require a response, this parameter is ignored and the resulting - * command will not retain the terminator information. - * - * The returned command handle is valid until the next call to any - * `ghostty_osc_*` function with the same parser instance with the exception - * of command introspection functions such as `ghostty_osc_command_type`. - * - * @param parser The parser handle, must not be null. - * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST) - * @return Handle to the parsed OSC command - * - * @ingroup osc - */ -GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); - -/** - * Get the type of an OSC command. - * - * Returns the type identifier for the given OSC command. This can be used - * to determine what kind of command was parsed and what data might be - * available from it. - * - * @param command The OSC command handle to query (may be NULL) - * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL - * - * @ingroup osc - */ -GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); - -/** - * Extract data from an OSC command. - * - * Extracts typed data from the given OSC command based on the specified - * data type. The output pointer must be of the appropriate type for the - * requested data kind. Valid command types, output types, and memory - * safety information are documented in the `GhosttyOscCommandData` enum. - * - * @param command The OSC command handle to query (may be NULL) - * @param data The type of data to extract - * @param out Pointer to store the extracted data (type depends on data parameter) - * @return true if data extraction was successful, false otherwise - * - * @ingroup osc - */ -bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); - -/** @} */ - -#endif /* GHOSTTY_VT_OSC_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/paste.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/paste.h deleted file mode 100644 index d90f303d4..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/paste.h +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @file paste.h - * - * Paste utilities - validate and encode paste data for terminal input. - */ - -#ifndef GHOSTTY_VT_PASTE_H -#define GHOSTTY_VT_PASTE_H - -/** @defgroup paste Paste Utilities - * - * Utilities for validating paste data safety. - * - * ## Basic Usage - * - * Use ghostty_paste_is_safe() to check if paste data contains potentially - * dangerous sequences before sending it to the terminal. - * - * ## Example - * - * @code{.c} - * #include - * #include - * #include - * - * int main() { - * const char* safe_data = "hello world"; - * const char* unsafe_data = "rm -rf /\n"; - * - * if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) { - * printf("Safe to paste\n"); - * } - * - * if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) { - * printf("Unsafe! Contains newline\n"); - * } - * - * return 0; - * } - * @endcode - * - * @{ - */ - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * Check if paste data is safe to paste into the terminal. - * - * Data is considered unsafe if it contains: - * - Newlines (`\n`) which can inject commands - * - The bracketed paste end sequence (`\x1b[201~`) which can be used - * to exit bracketed paste mode and inject commands - * - * This check is conservative and considers data unsafe regardless of - * current terminal state. - * - * @param data The paste data to check (must not be NULL) - * @param len The length of the data in bytes - * @return true if the data is safe to paste, false otherwise - */ -bool ghostty_paste_is_safe(const char* data, size_t len); - -#ifdef __cplusplus -} -#endif - -/** @} */ - -#endif /* GHOSTTY_VT_PASTE_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/result.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/result.h deleted file mode 100644 index 65938ee76..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/result.h +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @file result.h - * - * Result codes for libghostty-vt operations. - */ - -#ifndef GHOSTTY_VT_RESULT_H -#define GHOSTTY_VT_RESULT_H - -/** - * Result codes for libghostty-vt operations. - */ -typedef enum { - /** Operation completed successfully */ - GHOSTTY_SUCCESS = 0, - /** Operation failed due to failed allocation */ - GHOSTTY_OUT_OF_MEMORY = -1, - /** Operation failed due to invalid value */ - GHOSTTY_INVALID_VALUE = -2, -} GhosttyResult; - -#endif /* GHOSTTY_VT_RESULT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/sgr.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/sgr.h deleted file mode 100644 index 0c1afc309..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/sgr.h +++ /dev/null @@ -1,394 +0,0 @@ -/** - * @file sgr.h - * - * SGR (Select Graphic Rendition) attribute parsing and handling. - */ - -#ifndef GHOSTTY_VT_SGR_H -#define GHOSTTY_VT_SGR_H - -/** @defgroup sgr SGR Parser - * - * SGR (Select Graphic Rendition) attribute parser. - * - * SGR sequences are the syntax used to set styling attributes such as - * bold, italic, underline, and colors for text in terminal emulators. - * For example, you may be familiar with sequences like `ESC[1;31m`. The - * `1;31` is the SGR attribute list. - * - * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`) - * and returns individual text attributes like bold, italic, colors, etc. - * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed, - * and handles various color formats including 8-color, 16-color, 256-color, - * X11 named colors, and RGB in multiple formats. - * - * ## Basic Usage - * - * 1. Create a parser instance with ghostty_sgr_new() - * 2. Set SGR parameters with ghostty_sgr_set_params() - * 3. Iterate through attributes using ghostty_sgr_next() - * 4. Free the parser with ghostty_sgr_free() when done - * - * ## Example - * - * @code{.c} - * #include - * #include - * #include - * - * int main() { - * // Create parser - * GhosttySgrParser parser; - * GhosttyResult result = ghostty_sgr_new(NULL, &parser); - * assert(result == GHOSTTY_SUCCESS); - * - * // Parse "bold, red foreground" sequence: ESC[1;31m - * uint16_t params[] = {1, 31}; - * result = ghostty_sgr_set_params(parser, params, NULL, 2); - * assert(result == GHOSTTY_SUCCESS); - * - * // Iterate through attributes - * GhosttySgrAttribute attr; - * while (ghostty_sgr_next(parser, &attr)) { - * switch (attr.tag) { - * case GHOSTTY_SGR_ATTR_BOLD: - * printf("Bold enabled\n"); - * break; - * case GHOSTTY_SGR_ATTR_FG_8: - * printf("Foreground color: %d\n", attr.value.fg_8); - * break; - * default: - * break; - * } - * } - * - * // Cleanup - * ghostty_sgr_free(parser); - * return 0; - * } - * @endcode - * - * @{ - */ - -#include -#include -#include -#include -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * Opaque handle to an SGR parser instance. - * - * This handle represents an SGR (Select Graphic Rendition) parser that can - * be used to parse SGR sequences and extract individual text attributes. - * - * @ingroup sgr - */ -typedef struct GhosttySgrParser* GhosttySgrParser; - -/** - * SGR attribute tags. - * - * These values identify the type of an SGR attribute in a tagged union. - * Use the tag to determine which field in the attribute value union to access. - * - * @ingroup sgr - */ -typedef enum { - GHOSTTY_SGR_ATTR_UNSET = 0, - GHOSTTY_SGR_ATTR_UNKNOWN = 1, - GHOSTTY_SGR_ATTR_BOLD = 2, - GHOSTTY_SGR_ATTR_RESET_BOLD = 3, - GHOSTTY_SGR_ATTR_ITALIC = 4, - GHOSTTY_SGR_ATTR_RESET_ITALIC = 5, - GHOSTTY_SGR_ATTR_FAINT = 6, - GHOSTTY_SGR_ATTR_UNDERLINE = 7, - GHOSTTY_SGR_ATTR_RESET_UNDERLINE = 8, - GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 9, - GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 10, - GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 11, - GHOSTTY_SGR_ATTR_OVERLINE = 12, - GHOSTTY_SGR_ATTR_RESET_OVERLINE = 13, - GHOSTTY_SGR_ATTR_BLINK = 14, - GHOSTTY_SGR_ATTR_RESET_BLINK = 15, - GHOSTTY_SGR_ATTR_INVERSE = 16, - GHOSTTY_SGR_ATTR_RESET_INVERSE = 17, - GHOSTTY_SGR_ATTR_INVISIBLE = 18, - GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 19, - GHOSTTY_SGR_ATTR_STRIKETHROUGH = 20, - GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 21, - GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 22, - GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 23, - GHOSTTY_SGR_ATTR_BG_8 = 24, - GHOSTTY_SGR_ATTR_FG_8 = 25, - GHOSTTY_SGR_ATTR_RESET_FG = 26, - GHOSTTY_SGR_ATTR_RESET_BG = 27, - GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 28, - GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 29, - GHOSTTY_SGR_ATTR_BG_256 = 30, - GHOSTTY_SGR_ATTR_FG_256 = 31, -} GhosttySgrAttributeTag; - -/** - * Underline style types. - * - * @ingroup sgr - */ -typedef enum { - GHOSTTY_SGR_UNDERLINE_NONE = 0, - GHOSTTY_SGR_UNDERLINE_SINGLE = 1, - GHOSTTY_SGR_UNDERLINE_DOUBLE = 2, - GHOSTTY_SGR_UNDERLINE_CURLY = 3, - GHOSTTY_SGR_UNDERLINE_DOTTED = 4, - GHOSTTY_SGR_UNDERLINE_DASHED = 5, -} GhosttySgrUnderline; - -/** - * Unknown SGR attribute data. - * - * Contains the full parameter list and the partial list where parsing - * encountered an unknown or invalid sequence. - * - * @ingroup sgr - */ -typedef struct { - const uint16_t* full_ptr; - size_t full_len; - const uint16_t* partial_ptr; - size_t partial_len; -} GhosttySgrUnknown; - -/** - * SGR attribute value union. - * - * This union contains all possible attribute values. Use the tag field - * to determine which union member is active. Attributes without associated - * data (like bold, italic) don't use the union value. - * - * @ingroup sgr - */ -typedef union { - GhosttySgrUnknown unknown; - GhosttySgrUnderline underline; - GhosttyColorRgb underline_color; - GhosttyColorPaletteIndex underline_color_256; - GhosttyColorRgb direct_color_fg; - GhosttyColorRgb direct_color_bg; - GhosttyColorPaletteIndex bg_8; - GhosttyColorPaletteIndex fg_8; - GhosttyColorPaletteIndex bright_bg_8; - GhosttyColorPaletteIndex bright_fg_8; - GhosttyColorPaletteIndex bg_256; - GhosttyColorPaletteIndex fg_256; - uint64_t _padding[8]; -} GhosttySgrAttributeValue; - -/** - * SGR attribute (tagged union). - * - * A complete SGR attribute with both its type tag and associated value. - * Always check the tag field to determine which value union member is valid. - * - * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be - * identified by tag alone; the value union is not used for these and - * the memory in the value field is undefined. - * - * @ingroup sgr - */ -typedef struct { - GhosttySgrAttributeTag tag; - GhosttySgrAttributeValue value; -} GhosttySgrAttribute; - -/** - * Create a new SGR parser instance. - * - * Creates a new SGR (Select Graphic Rendition) parser using the provided - * allocator. The parser must be freed using ghostty_sgr_free() when - * no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or - * NULL to use the default allocator - * @param parser Pointer to store the created parser handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup sgr - */ -GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator, - GhosttySgrParser* parser); - -/** - * Free an SGR parser instance. - * - * Releases all resources associated with the SGR parser. After this call, - * the parser handle becomes invalid and must not be used. This includes - * any attributes previously returned by ghostty_sgr_next(). - * - * @param parser The parser handle to free (may be NULL) - * - * @ingroup sgr - */ -void ghostty_sgr_free(GhosttySgrParser parser); - -/** - * Reset an SGR parser instance to the beginning of the parameter list. - * - * Resets the parser's iteration state without clearing the parameters. - * After calling this, ghostty_sgr_next() will start from the beginning - * of the parameter list again. - * - * @param parser The parser handle to reset, must not be NULL - * - * @ingroup sgr - */ -void ghostty_sgr_reset(GhosttySgrParser parser); - -/** - * Set SGR parameters for parsing. - * - * Sets the SGR parameter list to parse. Parameters are the numeric values - * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}). - * - * The separators array optionally specifies the separator type for each - * parameter position. Each byte should be either ';' for semicolon or ':' - * for colon. This is needed for certain color formats that use colon - * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator - * values are treated as semicolons. The separators array must have the same - * length as the params array, if it is not NULL. - * - * If separators is NULL, all parameters are assumed to be semicolon-separated. - * - * This function makes an internal copy of the parameter and separator data, - * so the caller can safely free or modify the input arrays after this call. - * - * After calling this function, the parser is automatically reset and ready - * to iterate from the beginning. - * - * @param parser The parser handle, must not be NULL - * @param params Array of SGR parameter values - * @param separators Optional array of separator characters (';' or ':'), or - * NULL - * @param len Number of parameters (and separators if provided) - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup sgr - */ -GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser, - const uint16_t* params, - const char* separators, - size_t len); - -/** - * Get the next SGR attribute. - * - * Parses and returns the next attribute from the parameter list. - * Call this function repeatedly until it returns false to process - * all attributes in the sequence. - * - * @param parser The parser handle, must not be NULL - * @param attr Pointer to store the next attribute - * @return true if an attribute was returned, false if no more attributes - * - * @ingroup sgr - */ -bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr); - -/** - * Get the full parameter list from an unknown SGR attribute. - * - * This function retrieves the full parameter list that was provided to the - * parser when an unknown attribute was encountered. Primarily useful in - * WebAssembly environments where accessing struct fields directly is difficult. - * - * @param unknown The unknown attribute data - * @param ptr Pointer to store the pointer to the parameter array (may be NULL) - * @return The length of the full parameter array - * - * @ingroup sgr - */ -size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown, - const uint16_t** ptr); - -/** - * Get the partial parameter list from an unknown SGR attribute. - * - * This function retrieves the partial parameter list where parsing stopped - * when an unknown attribute was encountered. Primarily useful in WebAssembly - * environments where accessing struct fields directly is difficult. - * - * @param unknown The unknown attribute data - * @param ptr Pointer to store the pointer to the parameter array (may be NULL) - * @return The length of the partial parameter array - * - * @ingroup sgr - */ -size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown, - const uint16_t** ptr); - -/** - * Get the tag from an SGR attribute. - * - * This function extracts the tag that identifies which type of attribute - * this is. Primarily useful in WebAssembly environments where accessing - * struct fields directly is difficult. - * - * @param attr The SGR attribute - * @return The attribute tag - * - * @ingroup sgr - */ -GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr); - -/** - * Get the value from an SGR attribute. - * - * This function returns a pointer to the value union from an SGR attribute. Use - * the tag to determine which field of the union is valid. Primarily useful in - * WebAssembly environments where accessing struct fields directly is difficult. - * - * @param attr Pointer to the SGR attribute - * @return Pointer to the attribute value union - * - * @ingroup sgr - */ -GhosttySgrAttributeValue* ghostty_sgr_attribute_value( - GhosttySgrAttribute* attr); - -#ifdef __wasm__ -/** - * Allocate memory for an SGR attribute (WebAssembly only). - * - * This is a convenience function for WebAssembly environments to allocate - * memory for an SGR attribute structure that can be passed to ghostty_sgr_next. - * - * @return Pointer to the allocated attribute structure - * - * @ingroup wasm - */ -GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void); - -/** - * Free memory for an SGR attribute (WebAssembly only). - * - * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute. - * - * @param attr Pointer to the attribute structure to free - * - * @ingroup wasm - */ -void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr); -#endif - -#ifdef __cplusplus -} -#endif - -/** @} */ - -#endif /* GHOSTTY_VT_SGR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/wasm.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/wasm.h deleted file mode 100644 index 37a826326..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/wasm.h +++ /dev/null @@ -1,159 +0,0 @@ -/** - * @file wasm.h - * - * WebAssembly utility functions for libghostty-vt. - */ - -#ifndef GHOSTTY_VT_WASM_H -#define GHOSTTY_VT_WASM_H - -#ifdef __wasm__ - -#include -#include - -/** @defgroup wasm WebAssembly Utilities - * - * Convenience functions for allocating various types in WebAssembly builds. - * **These are only available the libghostty-vt wasm module.** - * - * Ghostty relies on pointers to various types for ABI compatibility, and - * creating those pointers in Wasm can be tedious. These functions provide - * a purely additive set of utilities that simplify memory management in - * Wasm environments without changing the core C library API. - * - * @note These functions always use the default allocator. If you need - * custom allocation strategies, you should allocate types manually using - * your custom allocator. This is a very rare use case in the WebAssembly - * world so these are optimized for simplicity. - * - * ## Example Usage - * - * Here's a simple example of using the Wasm utilities with the key encoder: - * - * @code - * const { exports } = wasmInstance; - * const view = new DataView(wasmMemory.buffer); - * - * // Create key encoder - * const encoderPtr = exports.ghostty_wasm_alloc_opaque(); - * exports.ghostty_key_encoder_new(null, encoderPtr); - * const encoder = view.getUint32(encoder, true); - * - * // Configure encoder with Kitty protocol flags - * const flagsPtr = exports.ghostty_wasm_alloc_u8(); - * view.setUint8(flagsPtr, 0x1F); - * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr); - * - * // Allocate output buffer and size pointer - * const bufferSize = 32; - * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize); - * const writtenPtr = exports.ghostty_wasm_alloc_usize(); - * - * // Encode the key event - * exports.ghostty_key_encoder_encode( - * encoder, eventPtr, bufPtr, bufferSize, writtenPtr - * ); - * - * // Read encoded output - * const bytesWritten = view.getUint32(writtenPtr, true); - * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten); - * @endcode - * - * @remark The code above is pretty ugly! This is the lowest level interface - * to the libghostty-vt Wasm module. In practice, this should be wrapped - * in a higher-level API that abstracts away all this. - * - * @{ - */ - -/** - * Allocate an opaque pointer. This can be used for any opaque pointer - * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc. - * - * @return Pointer to allocated opaque pointer, or NULL if allocation failed - * @ingroup wasm - */ -void** ghostty_wasm_alloc_opaque(void); - -/** - * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque(). - * - * @param ptr Pointer to free, or NULL (NULL is safely ignored) - * @ingroup wasm - */ -void ghostty_wasm_free_opaque(void **ptr); - -/** - * Allocate an array of uint8_t values. - * - * @param len Number of uint8_t elements to allocate - * @return Pointer to allocated array, or NULL if allocation failed - * @ingroup wasm - */ -uint8_t* ghostty_wasm_alloc_u8_array(size_t len); - -/** - * Free an array allocated by ghostty_wasm_alloc_u8_array(). - * - * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) - * @param len Length of the array (must match the length passed to alloc) - * @ingroup wasm - */ -void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len); - -/** - * Allocate an array of uint16_t values. - * - * @param len Number of uint16_t elements to allocate - * @return Pointer to allocated array, or NULL if allocation failed - * @ingroup wasm - */ -uint16_t* ghostty_wasm_alloc_u16_array(size_t len); - -/** - * Free an array allocated by ghostty_wasm_alloc_u16_array(). - * - * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) - * @param len Length of the array (must match the length passed to alloc) - * @ingroup wasm - */ -void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len); - -/** - * Allocate a single uint8_t value. - * - * @return Pointer to allocated uint8_t, or NULL if allocation failed - * @ingroup wasm - */ -uint8_t* ghostty_wasm_alloc_u8(void); - -/** - * Free a uint8_t allocated by ghostty_wasm_alloc_u8(). - * - * @param ptr Pointer to free, or NULL (NULL is safely ignored) - * @ingroup wasm - */ -void ghostty_wasm_free_u8(uint8_t *ptr); - -/** - * Allocate a single size_t value. - * - * @return Pointer to allocated size_t, or NULL if allocation failed - * @ingroup wasm - */ -size_t* ghostty_wasm_alloc_usize(void); - -/** - * Free a size_t allocated by ghostty_wasm_alloc_usize(). - * - * @param ptr Pointer to free, or NULL (NULL is safely ignored) - * @ingroup wasm - */ -void ghostty_wasm_free_usize(size_t *ptr); - -/** @} */ - -#endif /* __wasm__ */ - -#endif /* GHOSTTY_VT_WASM_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a index 788a46839..b8f96b69d 100644 Binary files a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a and b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a differ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty.h index 232e094ce..05ee8f182 100644 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty.h +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty.h @@ -1,10 +1,14 @@ -// Ghostty embedding API. The documentation for the embedding API is -// only within the Zig source files that define the implementations. This -// isn't meant to be a general purpose embedding API (yet) so there hasn't -// been documentation or example work beyond that. +// Ghostty's internal embedder API, a.k.a. "libghostty-internal". // -// The only consumer of this API is the macOS app, but the API is built to -// be more general purpose. +// The only consumer of this API is the macOS app, and while it is fairly +// comprehensive, it is tailored to the needs of the macOS app and not designed +// for external use, hence why most functions are undocumented and some are +// macOS-specific (e.g. ones dealing with the Metal graphics API). +// +// External embedders should instead use `libghostty-vt` or other related +// packages, which are extensively documented and designed from the ground up +// to be used in other software. Header files for which can be found in +// `include/ghostty/`. #ifndef GHOSTTY_H #define GHOSTTY_H @@ -68,7 +72,7 @@ typedef enum { GHOSTTY_PLATFORM_IOS, } ghostty_platform_e; -// Callback for custom I/O write handler. +// Callback for custom surface I/O writes. typedef void (*ghostty_surface_write_fn)(void* userdata, const uint8_t* data, size_t len); @@ -76,19 +80,56 @@ typedef void (*ghostty_surface_write_fn)(void* userdata, typedef enum { GHOSTTY_CLIPBOARD_STANDARD, GHOSTTY_CLIPBOARD_SELECTION, + GHOSTTY_CLIPBOARD_PRIMARY, } ghostty_clipboard_e; +// One representation of clipboard contents. The data is binary-safe with +// an explicit length; it is not necessarily null-terminated. typedef struct { const char *mime; const char *data; + size_t len; } ghostty_clipboard_content_s; +// The payload for completing a clipboard read request. See +// ghostty_surface_complete_clipboard_request. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + bool confirmed; + bool remember; +} ghostty_clipboard_complete_s; + +// The payload of a clipboard read confirmation request: the would-be +// completion contents plus the information shown in the permission +// prompt. See ghostty_runtime_confirm_read_clipboard_cb. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + const char *name; + bool can_remember; +} ghostty_clipboard_confirm_s; + typedef enum { GHOSTTY_CLIPBOARD_REQUEST_PASTE, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, + GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ, + GHOSTTY_CLIPBOARD_REQUEST_KITTY_WRITE, + GHOSTTY_CLIPBOARD_REQUEST_LIST, } ghostty_clipboard_request_e; +// apprt.ClipboardReadResult +typedef enum { + GHOSTTY_CLIPBOARD_READ_STARTED, + GHOSTTY_CLIPBOARD_READ_UNAVAILABLE, + GHOSTTY_CLIPBOARD_READ_UNSUPPORTED, +} ghostty_clipboard_read_result_e; + typedef enum { GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_PRESS, @@ -369,7 +410,6 @@ typedef enum { } ghostty_input_trigger_tag_e; typedef union { - ghostty_input_key_e translated; ghostty_input_key_e physical; uint32_t unicode; // catch_all has no payload @@ -652,6 +692,12 @@ typedef enum { GHOSTTY_INSPECTOR_HIDE, } ghostty_action_inspector_e; +// apprt.action.ExportTerminalIO.C +typedef struct { + const char* contents; + size_t len; +} ghostty_action_export_terminal_io_s; + // apprt.action.QuitTimer typedef enum { GHOSTTY_QUIT_TIMER_START, @@ -679,6 +725,7 @@ typedef struct { typedef enum { GHOSTTY_PROMPT_TITLE_SURFACE, GHOSTTY_PROMPT_TITLE_TAB, + GHOSTTY_PROMPT_TITLE_WINDOW, } ghostty_action_prompt_title_e; // apprt.action.Pwd.C @@ -686,6 +733,14 @@ typedef struct { const char* pwd; } ghostty_action_pwd_s; +// apprt.action.OpenConfig +typedef enum { + // Open the config in the OS default editor. + GHOSTTY_ACTION_OPEN_CONFIG_OS_OPEN, + // Open the config in a new window using $EDITOR or $VISUAL + GHOSTTY_ACTION_OPEN_CONFIG_NEW_WINDOW, +} ghostty_action_open_config_e; + // terminal.MouseShape typedef enum { GHOSTTY_MOUSE_SHAPE_DEFAULT, @@ -819,6 +874,7 @@ typedef enum { GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN, GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, GHOSTTY_ACTION_OPEN_URL_KIND_HTML, + GHOSTTY_ACTION_OPEN_URL_KIND_OSC8, } ghostty_action_open_url_kind_e; // apprt.action.OpenUrl.C @@ -921,9 +977,11 @@ typedef enum { GHOSTTY_ACTION_INSPECTOR, GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, GHOSTTY_ACTION_RENDER_INSPECTOR, + GHOSTTY_ACTION_EXPORT_TERMINAL_IO, GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, GHOSTTY_ACTION_SET_TAB_TITLE, + GHOSTTY_ACTION_SET_WINDOW_TITLE, GHOSTTY_ACTION_PROMPT_TITLE, GHOSTTY_ACTION_PWD, GHOSTTY_ACTION_MOUSE_SHAPE, @@ -941,6 +999,7 @@ typedef enum { GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, GHOSTTY_ACTION_RING_BELL, + GHOSTTY_ACTION_SELECTION_CHANGED, GHOSTTY_ACTION_UNDO, GHOSTTY_ACTION_REDO, GHOSTTY_ACTION_CHECK_FOR_UPDATES, @@ -955,6 +1014,7 @@ typedef enum { GHOSTTY_ACTION_SEARCH_SELECTED, GHOSTTY_ACTION_READONLY, GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD, + GHOSTTY_ACTION_MOVE_TAB_TO_NEW_WINDOW, } ghostty_action_tag_e; typedef union { @@ -970,6 +1030,7 @@ typedef union { ghostty_action_cell_size_s cell_size; ghostty_action_scrollbar_s scrollbar; ghostty_action_inspector_e inspector; + ghostty_action_export_terminal_io_s export_terminal_io; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; ghostty_action_set_title_s set_tab_title; @@ -996,6 +1057,7 @@ typedef union { ghostty_action_search_total_s search_total; ghostty_action_search_selected_s search_selected; ghostty_action_readonly_e readonly; + ghostty_action_open_config_e open_config; } ghostty_action_u; typedef struct { @@ -1004,12 +1066,16 @@ typedef struct { } ghostty_action_s; typedef void (*ghostty_runtime_wakeup_cb)(void*); -typedef bool (*ghostty_runtime_read_clipboard_cb)(void*, - ghostty_clipboard_e, - void*); +typedef ghostty_clipboard_read_result_e (*ghostty_runtime_read_clipboard_cb)( + void*, + ghostty_clipboard_e, + void*, + const char* const*, + size_t, + bool); typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( void*, - const char*, + const ghostty_clipboard_confirm_s*, void*, ghostty_clipboard_request_e); typedef void (*ghostty_runtime_write_clipboard_cb)(void*, @@ -1061,6 +1127,8 @@ typedef union { // apprt.ipc.Action.Key typedef enum { GHOSTTY_IPC_ACTION_NEW_WINDOW, + GHOSTTY_IPC_ACTION_NEW_TAB, + GHOSTTY_IPC_ACTION_TOGGLE_QUICK_TERMINAL, } ghostty_ipc_action_tag_e; //------------------------------------------------------------------- @@ -1084,6 +1152,7 @@ GHOSTTY_API bool ghostty_config_get(ghostty_config_t, void*, const char*, uintpt GHOSTTY_API ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, const char*, uintptr_t); +GHOSTTY_API bool ghostty_config_key_is_binding(ghostty_config_t, ghostty_input_key_s); GHOSTTY_API uint32_t ghostty_config_diagnostics_count(ghostty_config_t); GHOSTTY_API ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t); GHOSTTY_API ghostty_string_s ghostty_config_open_path(void); @@ -1095,7 +1164,6 @@ GHOSTTY_API void ghostty_app_tick(ghostty_app_t); GHOSTTY_API void* ghostty_app_userdata(ghostty_app_t); GHOSTTY_API void ghostty_app_set_focus(ghostty_app_t, bool); GHOSTTY_API bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); -GHOSTTY_API bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s); GHOSTTY_API void ghostty_app_keyboard_changed(ghostty_app_t); GHOSTTY_API void ghostty_app_open_config(ghostty_app_t); GHOSTTY_API void ghostty_app_update_config(ghostty_app_t, ghostty_config_t); @@ -1116,10 +1184,12 @@ GHOSTTY_API bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_process_exited(ghostty_surface_t); GHOSTTY_API void ghostty_surface_refresh(ghostty_surface_t); GHOSTTY_API void ghostty_surface_draw(ghostty_surface_t); -GHOSTTY_API void ghostty_surface_feed_data(ghostty_surface_t, const uint8_t*, size_t); +GHOSTTY_API void ghostty_surface_feed_data(ghostty_surface_t, + const uint8_t*, + size_t); GHOSTTY_API void ghostty_surface_set_write_callback(ghostty_surface_t, - ghostty_surface_write_fn, - void*); + ghostty_surface_write_fn, + void*); GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool); GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool); @@ -1161,10 +1231,12 @@ GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t, uint16_t); GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); -GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t, - const char*, - void*, - bool); +GHOSTTY_API void ghostty_surface_complete_clipboard_request( + ghostty_surface_t, + const ghostty_clipboard_complete_s*, + void*); +GHOSTTY_API void ghostty_surface_deny_clipboard_request(ghostty_surface_t, + void*); GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t, diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt.h deleted file mode 100644 index 4f8fef88e..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt.h +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @file vt.h - * - * libghostty-vt - Virtual terminal emulator library - * - * This library provides functionality for parsing and handling terminal - * escape sequences as well as maintaining terminal state such as styles, - * cursor position, screen, scrollback, and more. - * - * WARNING: This is an incomplete, work-in-progress API. It is not yet - * stable and is definitely going to change. - */ - -/** - * @mainpage libghostty-vt - Virtual Terminal Emulator Library - * - * libghostty-vt is a C library which implements a modern terminal emulator, - * extracted from the [Ghostty](https://ghostty.org) terminal emulator. - * - * libghostty-vt contains the logic for handling the core parts of a terminal - * emulator: parsing terminal escape sequences, maintaining terminal state, - * encoding input events, etc. It can handle scrollback, line wrapping, - * reflow on resize, and more. - * - * @warning This library is currently in development and the API is not yet stable. - * Breaking changes are expected in future versions. Use with caution in production code. - * - * @section groups_sec API Reference - * - * The API is organized into the following groups: - * - @ref key "Key Encoding" - Encode key events into terminal sequences - * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences - * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences - * - @ref paste "Paste Utilities" - Validate paste data safety - * - @ref allocator "Memory Management" - Memory management and custom allocators - * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions - * - * @section examples_sec Examples - * - * Complete working examples: - * - @ref c-vt/src/main.c - OSC parser example - * - @ref c-vt-key-encode/src/main.c - Key encoding example - * - @ref c-vt-paste/src/main.c - Paste safety check example - * - @ref c-vt-sgr/src/main.c - SGR parser example - * - */ - -/** @example c-vt/src/main.c - * This example demonstrates how to use the OSC parser to parse an OSC sequence, - * extract command information, and retrieve command-specific data like window titles. - */ - -/** @example c-vt-key-encode/src/main.c - * This example demonstrates how to use the key encoder to convert key events - * into terminal escape sequences using the Kitty keyboard protocol. - */ - -/** @example c-vt-paste/src/main.c - * This example demonstrates how to use the paste utilities to check if - * paste data is safe before sending it to the terminal. - */ - -/** @example c-vt-sgr/src/main.c - * This example demonstrates how to use the SGR parser to parse terminal - * styling sequences and extract text attributes like colors and underline styles. - */ - -#ifndef GHOSTTY_VT_H -#define GHOSTTY_VT_H - -#ifdef __cplusplus -extern "C" { -#endif - -#include -#include -#include -#include -#include -#include -#include - -#ifdef __cplusplus -} -#endif - -#endif /* GHOSTTY_VT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/allocator.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/allocator.h deleted file mode 100644 index 4cebe91bb..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/allocator.h +++ /dev/null @@ -1,196 +0,0 @@ -/** - * @file allocator.h - * - * Memory management interface for libghostty-vt. - */ - -#ifndef GHOSTTY_VT_ALLOCATOR_H -#define GHOSTTY_VT_ALLOCATOR_H - -#include -#include -#include - -/** @defgroup allocator Memory Management - * - * libghostty-vt does require memory allocation for various operations, - * but is resilient to allocation failures and will gracefully handle - * out-of-memory situations by returning error codes. - * - * The exact memory management semantics are documented in the relevant - * functions and data structures. - * - * libghostty-vt uses explicit memory allocation via an allocator - * interface provided by GhosttyAllocator. The interface is based on the - * [Zig](https://ziglang.org) allocator interface, since this has been - * shown to be a flexible and powerful interface in practice and enables - * a wide variety of allocation strategies. - * - * **For the common case, you can pass NULL as the allocator for any - * function that accepts one,** and libghostty will use a default allocator. - * The default allocator will be libc malloc/free if libc is linked. - * Otherwise, a custom allocator is used (currently Zig's SMP allocator) - * that doesn't require any external dependencies. - * - * ## Basic Usage - * - * For simple use cases, you can ignore this interface entirely by passing NULL - * as the allocator parameter to functions that accept one. This will use the - * default allocator (typically libc malloc/free, if libc is linked, but - * we provide our own default allocator if libc isn't linked). - * - * To use a custom allocator: - * 1. Implement the GhosttyAllocatorVtable function pointers - * 2. Create a GhosttyAllocator struct with your vtable and context - * 3. Pass the allocator to functions that accept one - * - * @{ - */ - -/** - * Function table for custom memory allocator operations. - * - * This vtable defines the interface for a custom memory allocator. All - * function pointers must be valid and non-NULL. - * - * @ingroup allocator - * - * If you're not going to use a custom allocator, you can ignore all of - * this. All functions that take an allocator pointer allow NULL to use a - * default allocator. - * - * The interface is based on the Zig allocator interface. I'll say up front - * that it is easy to look at this interface and think "wow, this is really - * overcomplicated". The reason for this complexity is well thought out by - * the Zig folks, and it enables a diverse set of allocation strategies - * as shown by the Zig ecosystem. As a consolation, please note that many - * of the arguments are only needed for advanced use cases and can be - * safely ignored in simple implementations. For example, if you look at - * the Zig implementation of the libc allocator in `lib/std/heap.zig` - * (search for CAllocator), you'll see it is very simple. - * - * We chose to align with the Zig allocator interface because: - * - * 1. It is a proven interface that serves a wide variety of use cases - * in the real world via the Zig ecosystem. It's shown to work. - * - * 2. Our core implementation itself is Zig, and this lets us very - * cheaply and easily convert between C and Zig allocators. - * - * NOTE(mitchellh): In the future, we can have default implementations of - * resize/remap and allow those to be null. - */ -typedef struct { - /** - * Return a pointer to `len` bytes with specified `alignment`, or return - * `NULL` indicating the allocation failed. - * - * @param ctx The allocator context - * @param len Number of bytes to allocate - * @param alignment Required alignment for the allocation. Guaranteed to - * be a power of two between 1 and 16 inclusive. - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return Pointer to allocated memory, or NULL if allocation failed - */ - void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr); - - /** - * Attempt to expand or shrink memory in place. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * `new_len` must be greater than zero. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to resize - * @param memory_len Current size of the memory block - * @param alignment Alignment (must match original allocation) - * @param new_len New requested size - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return true if resize was successful in-place, false if relocation would be required - */ - bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); - - /** - * Attempt to expand or shrink memory, allowing relocation. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * A non-`NULL` return value indicates the resize was successful. The - * allocation may have same address, or may have been relocated. In either - * case, the allocation now has size of `new_len`. A `NULL` return value - * indicates that the resize would be equivalent to allocating new memory, - * copying the bytes from the old memory, and then freeing the old memory. - * In such case, it is more efficient for the caller to perform the copy. - * - * `new_len` must be greater than zero. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to remap - * @param memory_len Current size of the memory block - * @param alignment Alignment (must match original allocation) - * @param new_len New requested size - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed - */ - void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); - - /** - * Free and invalidate a region of memory. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to free - * @param memory_len Size of the memory block - * @param alignment Alignment (must match original allocation) - * @param ret_addr First return address of the allocation call stack (0 if not provided) - */ - void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr); -} GhosttyAllocatorVtable; - -/** - * Custom memory allocator. - * - * For functions that take an allocator pointer, a NULL pointer indicates - * that the default allocator should be used. The default allocator will - * be libc malloc/free if we're linking to libc. If libc isn't linked, - * a custom allocator is used (currently Zig's SMP allocator). - * - * @ingroup allocator - * - * Usage example: - * @code - * GhosttyAllocator allocator = { - * .vtable = &my_allocator_vtable, - * .ctx = my_allocator_state - * }; - * @endcode - */ -typedef struct GhosttyAllocator { - /** - * Opaque context pointer passed to all vtable functions. - * This allows the allocator implementation to maintain state - * or reference external resources needed for memory management. - */ - void *ctx; - - /** - * Pointer to the allocator's vtable containing function pointers - * for memory operations (alloc, resize, remap, free). - */ - const GhosttyAllocatorVtable *vtable; -} GhosttyAllocator; - -/** @} */ - -#endif /* GHOSTTY_VT_ALLOCATOR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/color.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/color.h deleted file mode 100644 index 0d57b8db4..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/color.h +++ /dev/null @@ -1,96 +0,0 @@ -/** - * @file color.h - * - * Color types and utilities. - */ - -#ifndef GHOSTTY_VT_COLOR_H -#define GHOSTTY_VT_COLOR_H - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * RGB color value. - * - * @ingroup sgr - */ -typedef struct { - uint8_t r; /**< Red component (0-255) */ - uint8_t g; /**< Green component (0-255) */ - uint8_t b; /**< Blue component (0-255) */ -} GhosttyColorRgb; - -/** - * Palette color index (0-255). - * - * @ingroup sgr - */ -typedef uint8_t GhosttyColorPaletteIndex; - -/** @addtogroup sgr - * @{ - */ - -/** Black color (0) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BLACK 0 -/** Red color (1) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_RED 1 -/** Green color (2) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_GREEN 2 -/** Yellow color (3) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_YELLOW 3 -/** Blue color (4) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BLUE 4 -/** Magenta color (5) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_MAGENTA 5 -/** Cyan color (6) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_CYAN 6 -/** White color (7) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_WHITE 7 -/** Bright black color (8) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8 -/** Bright red color (9) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9 -/** Bright green color (10) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10 -/** Bright yellow color (11) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11 -/** Bright blue color (12) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12 -/** Bright magenta color (13) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13 -/** Bright cyan color (14) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14 -/** Bright white color (15) @ingroup sgr */ -#define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15 - -/** @} */ - -/** - * Get the RGB color components. - * - * This function extracts the individual red, green, and blue components - * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments - * where accessing struct fields directly is difficult. - * - * @param color The RGB color value - * @param r Pointer to store the red component (0-255) - * @param g Pointer to store the green component (0-255) - * @param b Pointer to store the blue component (0-255) - * - * @ingroup sgr - */ -void ghostty_color_rgb_get(GhosttyColorRgb color, - uint8_t* r, - uint8_t* g, - uint8_t* b); - -#ifdef __cplusplus -} -#endif - -#endif /* GHOSTTY_VT_COLOR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key.h deleted file mode 100644 index 772b5d43b..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key.h +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @file key.h - * - * Key encoding module - encode key events into terminal escape sequences. - */ - -#ifndef GHOSTTY_VT_KEY_H -#define GHOSTTY_VT_KEY_H - -/** @defgroup key Key Encoding - * - * Utilities for encoding key events into terminal escape sequences, - * supporting both legacy encoding as well as Kitty Keyboard Protocol. - * - * ## Basic Usage - * - * 1. Create an encoder instance with ghostty_key_encoder_new() - * 2. Configure encoder options with ghostty_key_encoder_setopt(). - * 3. For each key event: - * - Create a key event with ghostty_key_event_new() - * - Set event properties (action, key, modifiers, etc.) - * - Encode with ghostty_key_encoder_encode() - * - Free the event with ghostty_key_event_free() - * - Note: You can also reuse the same key event multiple times by - * changing its properties. - * 4. Free the encoder with ghostty_key_encoder_free() when done - * - * ## Example - * - * @code{.c} - * #include - * #include - * #include - * - * int main() { - * // Create encoder - * GhosttyKeyEncoder encoder; - * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); - * assert(result == GHOSTTY_SUCCESS); - * - * // Enable Kitty keyboard protocol with all features - * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, - * &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); - * - * // Create and configure key event for Ctrl+C press - * GhosttyKeyEvent event; - * result = ghostty_key_event_new(NULL, &event); - * assert(result == GHOSTTY_SUCCESS); - * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS); - * ghostty_key_event_set_key(event, GHOSTTY_KEY_C); - * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); - * - * // Encode the key event - * char buf[128]; - * size_t written = 0; - * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); - * assert(result == GHOSTTY_SUCCESS); - * - * // Use the encoded sequence (e.g., write to terminal) - * fwrite(buf, 1, written, stdout); - * - * // Cleanup - * ghostty_key_event_free(event); - * ghostty_key_encoder_free(encoder); - * return 0; - * } - * @endcode - * - * For a complete working example, see example/c-vt-key-encode in the - * repository. - * - * @{ - */ - -#include -#include - -/** @} */ - -#endif /* GHOSTTY_VT_KEY_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/encoder.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/encoder.h deleted file mode 100644 index 766a29427..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/encoder.h +++ /dev/null @@ -1,221 +0,0 @@ -/** - * @file encoder.h - * - * Key event encoding to terminal escape sequences. - */ - -#ifndef GHOSTTY_VT_KEY_ENCODER_H -#define GHOSTTY_VT_KEY_ENCODER_H - -#include -#include -#include -#include -#include - -/** - * Opaque handle to a key encoder instance. - * - * This handle represents a key encoder that converts key events into terminal - * escape sequences. - * - * @ingroup key - */ -typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder; - -/** - * Kitty keyboard protocol flags. - * - * Bitflags representing the various modes of the Kitty keyboard protocol. - * These can be combined using bitwise OR operations. Valid values all - * start with `GHOSTTY_KITTY_KEY_`. - * - * @ingroup key - */ -typedef uint8_t GhosttyKittyKeyFlags; - -/** Kitty keyboard protocol disabled (all flags off) */ -#define GHOSTTY_KITTY_KEY_DISABLED 0 - -/** Disambiguate escape codes */ -#define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0) - -/** Report key press and release events */ -#define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1) - -/** Report alternate key codes */ -#define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2) - -/** Report all key events including those normally handled by the terminal */ -#define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3) - -/** Report associated text with key events */ -#define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4) - -/** All Kitty keyboard protocol flags enabled */ -#define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED) - -/** - * macOS option key behavior. - * - * Determines whether the "option" key on macOS is treated as "alt" or not. - * See the Ghostty `macos-option-as-alt` configuration option for more details. - * - * @ingroup key - */ -typedef enum { - /** Option key is not treated as alt */ - GHOSTTY_OPTION_AS_ALT_FALSE = 0, - /** Option key is treated as alt */ - GHOSTTY_OPTION_AS_ALT_TRUE = 1, - /** Only left option key is treated as alt */ - GHOSTTY_OPTION_AS_ALT_LEFT = 2, - /** Only right option key is treated as alt */ - GHOSTTY_OPTION_AS_ALT_RIGHT = 3, -} GhosttyOptionAsAlt; - -/** - * Key encoder option identifiers. - * - * These values are used with ghostty_key_encoder_setopt() to configure - * the behavior of the key encoder. - * - * @ingroup key - */ -typedef enum { - /** Terminal DEC mode 1: cursor key application mode (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0, - - /** Terminal DEC mode 66: keypad key application mode (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1, - - /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2, - - /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3, - - /** xterm modifyOtherKeys mode 2 (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4, - - /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */ - GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5, - - /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */ - GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6, -} GhosttyKeyEncoderOption; - -/** - * Create a new key encoder instance. - * - * Creates a new key encoder with default options. The encoder can be configured - * using ghostty_key_encoder_setopt() and must be freed using - * ghostty_key_encoder_free() when no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param encoder Pointer to store the created encoder handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup key - */ -GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder); - -/** - * Free a key encoder instance. - * - * Releases all resources associated with the key encoder. After this call, - * the encoder handle becomes invalid and must not be used. - * - * @param encoder The encoder handle to free (may be NULL) - * - * @ingroup key - */ -void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); - -/** - * Set an option on the key encoder. - * - * Configures the behavior of the key encoder. Options control various aspects - * of encoding such as terminal modes (cursor key application mode, keypad mode), - * protocol selection (Kitty keyboard protocol flags), and platform-specific - * behaviors (macOS option-as-alt). - * - * A null pointer value does nothing. It does not reset the value to the - * default. The setopt call will do nothing. - * - * @param encoder The encoder handle, must not be NULL - * @param option The option to set - * @param value Pointer to the value to set (type depends on the option) - * - * @ingroup key - */ -void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value); - -/** - * Encode a key event into a terminal escape sequence. - * - * Converts a key event into the appropriate terminal escape sequence based on - * the encoder's current options. The sequence is written to the provided buffer. - * - * Not all key events produce output. For example, unmodified modifier keys - * typically don't generate escape sequences. Check the out_len parameter to - * determine if any data was written. - * - * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY - * and out_len will contain the required buffer size. The caller can then - * allocate a larger buffer and call the function again. - * - * @param encoder The encoder handle, must not be NULL - * @param event The key event to encode, must not be NULL - * @param out_buf Buffer to write the encoded sequence to - * @param out_buf_size Size of the output buffer in bytes - * @param out_len Pointer to store the number of bytes written (may be NULL) - * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code - * - * ## Example: Calculate required buffer size - * - * @code{.c} - * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY) - * size_t required = 0; - * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); - * assert(result == GHOSTTY_OUT_OF_MEMORY); - * - * // Allocate buffer of required size - * char *buf = malloc(required); - * - * // Encode with properly sized buffer - * size_t written = 0; - * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written); - * assert(result == GHOSTTY_SUCCESS); - * - * // Use the encoded sequence... - * - * free(buf); - * @endcode - * - * ## Example: Direct encoding with static buffer - * - * @code{.c} - * // Most escape sequences are short, so a static buffer often suffices - * char buf[128]; - * size_t written = 0; - * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); - * - * if (result == GHOSTTY_SUCCESS) { - * // Write the encoded sequence to the terminal - * write(pty_fd, buf, written); - * } else if (result == GHOSTTY_OUT_OF_MEMORY) { - * // Buffer too small, written contains required size - * char *dynamic_buf = malloc(written); - * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written); - * assert(result == GHOSTTY_SUCCESS); - * write(pty_fd, dynamic_buf, written); - * free(dynamic_buf); - * } - * @endcode - * - * @ingroup key - */ -GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len); - -#endif /* GHOSTTY_VT_KEY_ENCODER_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/event.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/event.h deleted file mode 100644 index dbd2e9f84..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/event.h +++ /dev/null @@ -1,474 +0,0 @@ -/** - * @file event.h - * - * Key event representation and manipulation. - */ - -#ifndef GHOSTTY_VT_KEY_EVENT_H -#define GHOSTTY_VT_KEY_EVENT_H - -#include -#include -#include -#include -#include - -/** - * Opaque handle to a key event. - * - * This handle represents a keyboard input event containing information about - * the physical key pressed, modifiers, and generated text. - * - * @ingroup key - */ -typedef struct GhosttyKeyEvent *GhosttyKeyEvent; - -/** - * Keyboard input event types. - * - * @ingroup key - */ -typedef enum { - /** Key was released */ - GHOSTTY_KEY_ACTION_RELEASE = 0, - /** Key was pressed */ - GHOSTTY_KEY_ACTION_PRESS = 1, - /** Key is being repeated (held down) */ - GHOSTTY_KEY_ACTION_REPEAT = 2, -} GhosttyKeyAction; - -/** - * Keyboard modifier keys bitmask. - * - * A bitmask representing all keyboard modifiers. This tracks which modifier keys - * are pressed and, where supported by the platform, which side (left or right) - * of each modifier is active. - * - * Use the GHOSTTY_MODS_* constants to test and set individual modifiers. - * - * Modifier side bits are only meaningful when the corresponding modifier bit is set. - * Not all platforms support distinguishing between left and right modifier - * keys and Ghostty is built to expect that some platforms may not provide this - * information. - * - * @ingroup key - */ -typedef uint16_t GhosttyMods; - -/** Shift key is pressed */ -#define GHOSTTY_MODS_SHIFT (1 << 0) -/** Control key is pressed */ -#define GHOSTTY_MODS_CTRL (1 << 1) -/** Alt/Option key is pressed */ -#define GHOSTTY_MODS_ALT (1 << 2) -/** Super/Command/Windows key is pressed */ -#define GHOSTTY_MODS_SUPER (1 << 3) -/** Caps Lock is active */ -#define GHOSTTY_MODS_CAPS_LOCK (1 << 4) -/** Num Lock is active */ -#define GHOSTTY_MODS_NUM_LOCK (1 << 5) - -/** - * Right shift is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_SHIFT is set. - */ -#define GHOSTTY_MODS_SHIFT_SIDE (1 << 6) -/** - * Right ctrl is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_CTRL is set. - */ -#define GHOSTTY_MODS_CTRL_SIDE (1 << 7) -/** - * Right alt is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_ALT is set. - */ -#define GHOSTTY_MODS_ALT_SIDE (1 << 8) -/** - * Right super is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_SUPER is set. - */ -#define GHOSTTY_MODS_SUPER_SIDE (1 << 9) - -/** - * Physical key codes. - * - * The set of key codes that Ghostty is aware of. These represent physical keys - * on the keyboard and are layout-independent. For example, the "a" key on a US - * keyboard is the same as the "ф" key on a Russian keyboard, but both will - * report the same key_a value. - * - * Layout-dependent strings are provided separately as UTF-8 text and are produced - * by the platform. These values are based on the W3C UI Events KeyboardEvent code - * standard. See: https://www.w3.org/TR/uievents-code - * - * @ingroup key - */ -typedef enum { - GHOSTTY_KEY_UNIDENTIFIED = 0, - - // Writing System Keys (W3C § 3.1.1) - GHOSTTY_KEY_BACKQUOTE, - GHOSTTY_KEY_BACKSLASH, - GHOSTTY_KEY_BRACKET_LEFT, - GHOSTTY_KEY_BRACKET_RIGHT, - GHOSTTY_KEY_COMMA, - GHOSTTY_KEY_DIGIT_0, - GHOSTTY_KEY_DIGIT_1, - GHOSTTY_KEY_DIGIT_2, - GHOSTTY_KEY_DIGIT_3, - GHOSTTY_KEY_DIGIT_4, - GHOSTTY_KEY_DIGIT_5, - GHOSTTY_KEY_DIGIT_6, - GHOSTTY_KEY_DIGIT_7, - GHOSTTY_KEY_DIGIT_8, - GHOSTTY_KEY_DIGIT_9, - GHOSTTY_KEY_EQUAL, - GHOSTTY_KEY_INTL_BACKSLASH, - GHOSTTY_KEY_INTL_RO, - GHOSTTY_KEY_INTL_YEN, - GHOSTTY_KEY_A, - GHOSTTY_KEY_B, - GHOSTTY_KEY_C, - GHOSTTY_KEY_D, - GHOSTTY_KEY_E, - GHOSTTY_KEY_F, - GHOSTTY_KEY_G, - GHOSTTY_KEY_H, - GHOSTTY_KEY_I, - GHOSTTY_KEY_J, - GHOSTTY_KEY_K, - GHOSTTY_KEY_L, - GHOSTTY_KEY_M, - GHOSTTY_KEY_N, - GHOSTTY_KEY_O, - GHOSTTY_KEY_P, - GHOSTTY_KEY_Q, - GHOSTTY_KEY_R, - GHOSTTY_KEY_S, - GHOSTTY_KEY_T, - GHOSTTY_KEY_U, - GHOSTTY_KEY_V, - GHOSTTY_KEY_W, - GHOSTTY_KEY_X, - GHOSTTY_KEY_Y, - GHOSTTY_KEY_Z, - GHOSTTY_KEY_MINUS, - GHOSTTY_KEY_PERIOD, - GHOSTTY_KEY_QUOTE, - GHOSTTY_KEY_SEMICOLON, - GHOSTTY_KEY_SLASH, - - // Functional Keys (W3C § 3.1.2) - GHOSTTY_KEY_ALT_LEFT, - GHOSTTY_KEY_ALT_RIGHT, - GHOSTTY_KEY_BACKSPACE, - GHOSTTY_KEY_CAPS_LOCK, - GHOSTTY_KEY_CONTEXT_MENU, - GHOSTTY_KEY_CONTROL_LEFT, - GHOSTTY_KEY_CONTROL_RIGHT, - GHOSTTY_KEY_ENTER, - GHOSTTY_KEY_META_LEFT, - GHOSTTY_KEY_META_RIGHT, - GHOSTTY_KEY_SHIFT_LEFT, - GHOSTTY_KEY_SHIFT_RIGHT, - GHOSTTY_KEY_SPACE, - GHOSTTY_KEY_TAB, - GHOSTTY_KEY_CONVERT, - GHOSTTY_KEY_KANA_MODE, - GHOSTTY_KEY_NON_CONVERT, - - // Control Pad Section (W3C § 3.2) - GHOSTTY_KEY_DELETE, - GHOSTTY_KEY_END, - GHOSTTY_KEY_HELP, - GHOSTTY_KEY_HOME, - GHOSTTY_KEY_INSERT, - GHOSTTY_KEY_PAGE_DOWN, - GHOSTTY_KEY_PAGE_UP, - - // Arrow Pad Section (W3C § 3.3) - GHOSTTY_KEY_ARROW_DOWN, - GHOSTTY_KEY_ARROW_LEFT, - GHOSTTY_KEY_ARROW_RIGHT, - GHOSTTY_KEY_ARROW_UP, - - // Numpad Section (W3C § 3.4) - GHOSTTY_KEY_NUM_LOCK, - GHOSTTY_KEY_NUMPAD_0, - GHOSTTY_KEY_NUMPAD_1, - GHOSTTY_KEY_NUMPAD_2, - GHOSTTY_KEY_NUMPAD_3, - GHOSTTY_KEY_NUMPAD_4, - GHOSTTY_KEY_NUMPAD_5, - GHOSTTY_KEY_NUMPAD_6, - GHOSTTY_KEY_NUMPAD_7, - GHOSTTY_KEY_NUMPAD_8, - GHOSTTY_KEY_NUMPAD_9, - GHOSTTY_KEY_NUMPAD_ADD, - GHOSTTY_KEY_NUMPAD_BACKSPACE, - GHOSTTY_KEY_NUMPAD_CLEAR, - GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, - GHOSTTY_KEY_NUMPAD_COMMA, - GHOSTTY_KEY_NUMPAD_DECIMAL, - GHOSTTY_KEY_NUMPAD_DIVIDE, - GHOSTTY_KEY_NUMPAD_ENTER, - GHOSTTY_KEY_NUMPAD_EQUAL, - GHOSTTY_KEY_NUMPAD_MEMORY_ADD, - GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, - GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, - GHOSTTY_KEY_NUMPAD_MEMORY_STORE, - GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, - GHOSTTY_KEY_NUMPAD_MULTIPLY, - GHOSTTY_KEY_NUMPAD_PAREN_LEFT, - GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, - GHOSTTY_KEY_NUMPAD_SUBTRACT, - GHOSTTY_KEY_NUMPAD_SEPARATOR, - GHOSTTY_KEY_NUMPAD_UP, - GHOSTTY_KEY_NUMPAD_DOWN, - GHOSTTY_KEY_NUMPAD_RIGHT, - GHOSTTY_KEY_NUMPAD_LEFT, - GHOSTTY_KEY_NUMPAD_BEGIN, - GHOSTTY_KEY_NUMPAD_HOME, - GHOSTTY_KEY_NUMPAD_END, - GHOSTTY_KEY_NUMPAD_INSERT, - GHOSTTY_KEY_NUMPAD_DELETE, - GHOSTTY_KEY_NUMPAD_PAGE_UP, - GHOSTTY_KEY_NUMPAD_PAGE_DOWN, - - // Function Section (W3C § 3.5) - GHOSTTY_KEY_ESCAPE, - GHOSTTY_KEY_F1, - GHOSTTY_KEY_F2, - GHOSTTY_KEY_F3, - GHOSTTY_KEY_F4, - GHOSTTY_KEY_F5, - GHOSTTY_KEY_F6, - GHOSTTY_KEY_F7, - GHOSTTY_KEY_F8, - GHOSTTY_KEY_F9, - GHOSTTY_KEY_F10, - GHOSTTY_KEY_F11, - GHOSTTY_KEY_F12, - GHOSTTY_KEY_F13, - GHOSTTY_KEY_F14, - GHOSTTY_KEY_F15, - GHOSTTY_KEY_F16, - GHOSTTY_KEY_F17, - GHOSTTY_KEY_F18, - GHOSTTY_KEY_F19, - GHOSTTY_KEY_F20, - GHOSTTY_KEY_F21, - GHOSTTY_KEY_F22, - GHOSTTY_KEY_F23, - GHOSTTY_KEY_F24, - GHOSTTY_KEY_F25, - GHOSTTY_KEY_FN, - GHOSTTY_KEY_FN_LOCK, - GHOSTTY_KEY_PRINT_SCREEN, - GHOSTTY_KEY_SCROLL_LOCK, - GHOSTTY_KEY_PAUSE, - - // Media Keys (W3C § 3.6) - GHOSTTY_KEY_BROWSER_BACK, - GHOSTTY_KEY_BROWSER_FAVORITES, - GHOSTTY_KEY_BROWSER_FORWARD, - GHOSTTY_KEY_BROWSER_HOME, - GHOSTTY_KEY_BROWSER_REFRESH, - GHOSTTY_KEY_BROWSER_SEARCH, - GHOSTTY_KEY_BROWSER_STOP, - GHOSTTY_KEY_EJECT, - GHOSTTY_KEY_LAUNCH_APP_1, - GHOSTTY_KEY_LAUNCH_APP_2, - GHOSTTY_KEY_LAUNCH_MAIL, - GHOSTTY_KEY_MEDIA_PLAY_PAUSE, - GHOSTTY_KEY_MEDIA_SELECT, - GHOSTTY_KEY_MEDIA_STOP, - GHOSTTY_KEY_MEDIA_TRACK_NEXT, - GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, - GHOSTTY_KEY_POWER, - GHOSTTY_KEY_SLEEP, - GHOSTTY_KEY_AUDIO_VOLUME_DOWN, - GHOSTTY_KEY_AUDIO_VOLUME_MUTE, - GHOSTTY_KEY_AUDIO_VOLUME_UP, - GHOSTTY_KEY_WAKE_UP, - - // Legacy, Non-standard, and Special Keys (W3C § 3.7) - GHOSTTY_KEY_COPY, - GHOSTTY_KEY_CUT, - GHOSTTY_KEY_PASTE, -} GhosttyKey; - -/** - * Create a new key event instance. - * - * Creates a new key event with default values. The event must be freed using - * ghostty_key_event_free() when no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param event Pointer to store the created key event handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup key - */ -GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event); - -/** - * Free a key event instance. - * - * Releases all resources associated with the key event. After this call, - * the event handle becomes invalid and must not be used. - * - * @param event The key event handle to free (may be NULL) - * - * @ingroup key - */ -void ghostty_key_event_free(GhosttyKeyEvent event); - -/** - * Set the key action (press, release, repeat). - * - * @param event The key event handle, must not be NULL - * @param action The action to set - * - * @ingroup key - */ -void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action); - -/** - * Get the key action (press, release, repeat). - * - * @param event The key event handle, must not be NULL - * @return The key action - * - * @ingroup key - */ -GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event); - -/** - * Set the physical key code. - * - * @param event The key event handle, must not be NULL - * @param key The physical key code to set - * - * @ingroup key - */ -void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key); - -/** - * Get the physical key code. - * - * @param event The key event handle, must not be NULL - * @return The physical key code - * - * @ingroup key - */ -GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event); - -/** - * Set the modifier keys bitmask. - * - * @param event The key event handle, must not be NULL - * @param mods The modifier keys bitmask to set - * - * @ingroup key - */ -void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods); - -/** - * Get the modifier keys bitmask. - * - * @param event The key event handle, must not be NULL - * @return The modifier keys bitmask - * - * @ingroup key - */ -GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event); - -/** - * Set the consumed modifiers bitmask. - * - * @param event The key event handle, must not be NULL - * @param consumed_mods The consumed modifiers bitmask to set - * - * @ingroup key - */ -void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods); - -/** - * Get the consumed modifiers bitmask. - * - * @param event The key event handle, must not be NULL - * @return The consumed modifiers bitmask - * - * @ingroup key - */ -GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event); - -/** - * Set whether the key event is part of a composition sequence. - * - * @param event The key event handle, must not be NULL - * @param composing Whether the key event is part of a composition sequence - * - * @ingroup key - */ -void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing); - -/** - * Get whether the key event is part of a composition sequence. - * - * @param event The key event handle, must not be NULL - * @return Whether the key event is part of a composition sequence - * - * @ingroup key - */ -bool ghostty_key_event_get_composing(GhosttyKeyEvent event); - -/** - * Set the UTF-8 text generated by the key event. - * - * The key event does NOT take ownership of the text pointer. The caller - * must ensure the string remains valid for the lifetime needed by the event. - * - * @param event The key event handle, must not be NULL - * @param utf8 The UTF-8 text to set (or NULL for empty) - * @param len Length of the UTF-8 text in bytes - * - * @ingroup key - */ -void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len); - -/** - * Get the UTF-8 text generated by the key event. - * - * The returned pointer is valid until the event is freed or the UTF-8 text is modified. - * - * @param event The key event handle, must not be NULL - * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL) - * @return The UTF-8 text (or NULL for empty) - * - * @ingroup key - */ -const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len); - -/** - * Set the unshifted Unicode codepoint. - * - * @param event The key event handle, must not be NULL - * @param codepoint The unshifted Unicode codepoint to set - * - * @ingroup key - */ -void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); - -/** - * Get the unshifted Unicode codepoint. - * - * @param event The key event handle, must not be NULL - * @return The unshifted Unicode codepoint - * - * @ingroup key - */ -uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); - -#endif /* GHOSTTY_VT_KEY_EVENT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/osc.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/osc.h deleted file mode 100644 index f53077ab3..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/osc.h +++ /dev/null @@ -1,233 +0,0 @@ -/** - * @file osc.h - * - * OSC (Operating System Command) sequence parser and command handling. - */ - -#ifndef GHOSTTY_VT_OSC_H -#define GHOSTTY_VT_OSC_H - -#include -#include -#include -#include -#include - -/** - * Opaque handle to an OSC parser instance. - * - * This handle represents an OSC (Operating System Command) parser that can - * be used to parse the contents of OSC sequences. - * - * @ingroup osc - */ -typedef struct GhosttyOscParser *GhosttyOscParser; - -/** - * Opaque handle to a single OSC command. - * - * This handle represents a parsed OSC (Operating System Command) command. - * The command can be queried for its type and associated data. - * - * @ingroup osc - */ -typedef struct GhosttyOscCommand *GhosttyOscCommand; - -/** @defgroup osc OSC Parser - * - * OSC (Operating System Command) sequence parser and command handling. - * - * The parser operates in a streaming fashion, processing input byte-by-byte - * to handle OSC sequences that may arrive in fragments across multiple reads. - * This interface makes it easy to integrate into most environments and avoids - * over-allocating buffers. - * - * ## Basic Usage - * - * 1. Create a parser instance with ghostty_osc_new() - * 2. Feed bytes to the parser using ghostty_osc_next() - * 3. Finalize parsing with ghostty_osc_end() to get the command - * 4. Query command type and extract data using ghostty_osc_command_type() - * and ghostty_osc_command_data() - * 5. Free the parser with ghostty_osc_free() when done - * - * @{ - */ - -/** - * OSC command types. - * - * @ingroup osc - */ -typedef enum { - GHOSTTY_OSC_COMMAND_INVALID = 0, - GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, - GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, - GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT = 3, - GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 4, - GHOSTTY_OSC_COMMAND_REPORT_PWD = 5, - GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 6, - GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 7, - GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 8, - GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 9, - GHOSTTY_OSC_COMMAND_HYPERLINK_START = 10, - GHOSTTY_OSC_COMMAND_HYPERLINK_END = 11, - GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 12, - GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 13, - GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 14, - GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 15, - GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 16, - GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 17, - GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS = 18, - GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE = 19, - GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20, - GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21, - GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22, -} GhosttyOscCommandType; - -/** - * OSC command data types. - * - * These values specify what type of data to extract from an OSC command - * using `ghostty_osc_command_data`. - * - * @ingroup osc - */ -typedef enum { - /** Invalid data type. Never results in any data extraction. */ - GHOSTTY_OSC_DATA_INVALID = 0, - - /** - * Window title string data. - * - * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE - * - * Output type: const char ** (pointer to null-terminated string) - * - * Lifetime: Valid until the next call to any ghostty_osc_* function with - * the same parser instance. Memory is owned by the parser. - */ - GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, -} GhosttyOscCommandData; - -/** - * Create a new OSC parser instance. - * - * Creates a new OSC (Operating System Command) parser using the provided - * allocator. The parser must be freed using ghostty_vt_osc_free() when - * no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param parser Pointer to store the created parser handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup osc - */ -GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser); - -/** - * Free an OSC parser instance. - * - * Releases all resources associated with the OSC parser. After this call, - * the parser handle becomes invalid and must not be used. - * - * @param parser The parser handle to free (may be NULL) - * - * @ingroup osc - */ -void ghostty_osc_free(GhosttyOscParser parser); - -/** - * Reset an OSC parser instance to its initial state. - * - * Resets the parser state, clearing any partially parsed OSC sequences - * and returning the parser to its initial state. This is useful for - * reusing a parser instance or recovering from parse errors. - * - * @param parser The parser handle to reset, must not be null. - * - * @ingroup osc - */ -void ghostty_osc_reset(GhosttyOscParser parser); - -/** - * Parse the next byte in an OSC sequence. - * - * Processes a single byte as part of an OSC sequence. The parser maintains - * internal state to track the progress through the sequence. Call this - * function for each byte in the sequence data. - * - * When finished pumping the parser with bytes, call ghostty_osc_end - * to get the final result. - * - * @param parser The parser handle, must not be null. - * @param byte The next byte to parse - * - * @ingroup osc - */ -void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); - -/** - * Finalize OSC parsing and retrieve the parsed command. - * - * Call this function after feeding all bytes of an OSC sequence to the parser - * using ghostty_osc_next() with the exception of the terminating character - * (ESC or ST). This function finalizes the parsing process and returns the - * parsed OSC command. - * - * The return value is never NULL. Invalid commands will return a command - * with type GHOSTTY_OSC_COMMAND_INVALID. - * - * The terminator parameter specifies the byte that terminated the OSC sequence - * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is - * preserved in the parsed command so that responses can use the same terminator - * format for better compatibility with the calling program. For commands that - * do not require a response, this parameter is ignored and the resulting - * command will not retain the terminator information. - * - * The returned command handle is valid until the next call to any - * `ghostty_osc_*` function with the same parser instance with the exception - * of command introspection functions such as `ghostty_osc_command_type`. - * - * @param parser The parser handle, must not be null. - * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST) - * @return Handle to the parsed OSC command - * - * @ingroup osc - */ -GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); - -/** - * Get the type of an OSC command. - * - * Returns the type identifier for the given OSC command. This can be used - * to determine what kind of command was parsed and what data might be - * available from it. - * - * @param command The OSC command handle to query (may be NULL) - * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL - * - * @ingroup osc - */ -GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); - -/** - * Extract data from an OSC command. - * - * Extracts typed data from the given OSC command based on the specified - * data type. The output pointer must be of the appropriate type for the - * requested data kind. Valid command types, output types, and memory - * safety information are documented in the `GhosttyOscCommandData` enum. - * - * @param command The OSC command handle to query (may be NULL) - * @param data The type of data to extract - * @param out Pointer to store the extracted data (type depends on data parameter) - * @return true if data extraction was successful, false otherwise - * - * @ingroup osc - */ -bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); - -/** @} */ - -#endif /* GHOSTTY_VT_OSC_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/paste.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/paste.h deleted file mode 100644 index d90f303d4..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/paste.h +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @file paste.h - * - * Paste utilities - validate and encode paste data for terminal input. - */ - -#ifndef GHOSTTY_VT_PASTE_H -#define GHOSTTY_VT_PASTE_H - -/** @defgroup paste Paste Utilities - * - * Utilities for validating paste data safety. - * - * ## Basic Usage - * - * Use ghostty_paste_is_safe() to check if paste data contains potentially - * dangerous sequences before sending it to the terminal. - * - * ## Example - * - * @code{.c} - * #include - * #include - * #include - * - * int main() { - * const char* safe_data = "hello world"; - * const char* unsafe_data = "rm -rf /\n"; - * - * if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) { - * printf("Safe to paste\n"); - * } - * - * if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) { - * printf("Unsafe! Contains newline\n"); - * } - * - * return 0; - * } - * @endcode - * - * @{ - */ - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * Check if paste data is safe to paste into the terminal. - * - * Data is considered unsafe if it contains: - * - Newlines (`\n`) which can inject commands - * - The bracketed paste end sequence (`\x1b[201~`) which can be used - * to exit bracketed paste mode and inject commands - * - * This check is conservative and considers data unsafe regardless of - * current terminal state. - * - * @param data The paste data to check (must not be NULL) - * @param len The length of the data in bytes - * @return true if the data is safe to paste, false otherwise - */ -bool ghostty_paste_is_safe(const char* data, size_t len); - -#ifdef __cplusplus -} -#endif - -/** @} */ - -#endif /* GHOSTTY_VT_PASTE_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/result.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/result.h deleted file mode 100644 index 65938ee76..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/result.h +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @file result.h - * - * Result codes for libghostty-vt operations. - */ - -#ifndef GHOSTTY_VT_RESULT_H -#define GHOSTTY_VT_RESULT_H - -/** - * Result codes for libghostty-vt operations. - */ -typedef enum { - /** Operation completed successfully */ - GHOSTTY_SUCCESS = 0, - /** Operation failed due to failed allocation */ - GHOSTTY_OUT_OF_MEMORY = -1, - /** Operation failed due to invalid value */ - GHOSTTY_INVALID_VALUE = -2, -} GhosttyResult; - -#endif /* GHOSTTY_VT_RESULT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/sgr.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/sgr.h deleted file mode 100644 index 0c1afc309..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/sgr.h +++ /dev/null @@ -1,394 +0,0 @@ -/** - * @file sgr.h - * - * SGR (Select Graphic Rendition) attribute parsing and handling. - */ - -#ifndef GHOSTTY_VT_SGR_H -#define GHOSTTY_VT_SGR_H - -/** @defgroup sgr SGR Parser - * - * SGR (Select Graphic Rendition) attribute parser. - * - * SGR sequences are the syntax used to set styling attributes such as - * bold, italic, underline, and colors for text in terminal emulators. - * For example, you may be familiar with sequences like `ESC[1;31m`. The - * `1;31` is the SGR attribute list. - * - * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`) - * and returns individual text attributes like bold, italic, colors, etc. - * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed, - * and handles various color formats including 8-color, 16-color, 256-color, - * X11 named colors, and RGB in multiple formats. - * - * ## Basic Usage - * - * 1. Create a parser instance with ghostty_sgr_new() - * 2. Set SGR parameters with ghostty_sgr_set_params() - * 3. Iterate through attributes using ghostty_sgr_next() - * 4. Free the parser with ghostty_sgr_free() when done - * - * ## Example - * - * @code{.c} - * #include - * #include - * #include - * - * int main() { - * // Create parser - * GhosttySgrParser parser; - * GhosttyResult result = ghostty_sgr_new(NULL, &parser); - * assert(result == GHOSTTY_SUCCESS); - * - * // Parse "bold, red foreground" sequence: ESC[1;31m - * uint16_t params[] = {1, 31}; - * result = ghostty_sgr_set_params(parser, params, NULL, 2); - * assert(result == GHOSTTY_SUCCESS); - * - * // Iterate through attributes - * GhosttySgrAttribute attr; - * while (ghostty_sgr_next(parser, &attr)) { - * switch (attr.tag) { - * case GHOSTTY_SGR_ATTR_BOLD: - * printf("Bold enabled\n"); - * break; - * case GHOSTTY_SGR_ATTR_FG_8: - * printf("Foreground color: %d\n", attr.value.fg_8); - * break; - * default: - * break; - * } - * } - * - * // Cleanup - * ghostty_sgr_free(parser); - * return 0; - * } - * @endcode - * - * @{ - */ - -#include -#include -#include -#include -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * Opaque handle to an SGR parser instance. - * - * This handle represents an SGR (Select Graphic Rendition) parser that can - * be used to parse SGR sequences and extract individual text attributes. - * - * @ingroup sgr - */ -typedef struct GhosttySgrParser* GhosttySgrParser; - -/** - * SGR attribute tags. - * - * These values identify the type of an SGR attribute in a tagged union. - * Use the tag to determine which field in the attribute value union to access. - * - * @ingroup sgr - */ -typedef enum { - GHOSTTY_SGR_ATTR_UNSET = 0, - GHOSTTY_SGR_ATTR_UNKNOWN = 1, - GHOSTTY_SGR_ATTR_BOLD = 2, - GHOSTTY_SGR_ATTR_RESET_BOLD = 3, - GHOSTTY_SGR_ATTR_ITALIC = 4, - GHOSTTY_SGR_ATTR_RESET_ITALIC = 5, - GHOSTTY_SGR_ATTR_FAINT = 6, - GHOSTTY_SGR_ATTR_UNDERLINE = 7, - GHOSTTY_SGR_ATTR_RESET_UNDERLINE = 8, - GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 9, - GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 10, - GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 11, - GHOSTTY_SGR_ATTR_OVERLINE = 12, - GHOSTTY_SGR_ATTR_RESET_OVERLINE = 13, - GHOSTTY_SGR_ATTR_BLINK = 14, - GHOSTTY_SGR_ATTR_RESET_BLINK = 15, - GHOSTTY_SGR_ATTR_INVERSE = 16, - GHOSTTY_SGR_ATTR_RESET_INVERSE = 17, - GHOSTTY_SGR_ATTR_INVISIBLE = 18, - GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 19, - GHOSTTY_SGR_ATTR_STRIKETHROUGH = 20, - GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 21, - GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 22, - GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 23, - GHOSTTY_SGR_ATTR_BG_8 = 24, - GHOSTTY_SGR_ATTR_FG_8 = 25, - GHOSTTY_SGR_ATTR_RESET_FG = 26, - GHOSTTY_SGR_ATTR_RESET_BG = 27, - GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 28, - GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 29, - GHOSTTY_SGR_ATTR_BG_256 = 30, - GHOSTTY_SGR_ATTR_FG_256 = 31, -} GhosttySgrAttributeTag; - -/** - * Underline style types. - * - * @ingroup sgr - */ -typedef enum { - GHOSTTY_SGR_UNDERLINE_NONE = 0, - GHOSTTY_SGR_UNDERLINE_SINGLE = 1, - GHOSTTY_SGR_UNDERLINE_DOUBLE = 2, - GHOSTTY_SGR_UNDERLINE_CURLY = 3, - GHOSTTY_SGR_UNDERLINE_DOTTED = 4, - GHOSTTY_SGR_UNDERLINE_DASHED = 5, -} GhosttySgrUnderline; - -/** - * Unknown SGR attribute data. - * - * Contains the full parameter list and the partial list where parsing - * encountered an unknown or invalid sequence. - * - * @ingroup sgr - */ -typedef struct { - const uint16_t* full_ptr; - size_t full_len; - const uint16_t* partial_ptr; - size_t partial_len; -} GhosttySgrUnknown; - -/** - * SGR attribute value union. - * - * This union contains all possible attribute values. Use the tag field - * to determine which union member is active. Attributes without associated - * data (like bold, italic) don't use the union value. - * - * @ingroup sgr - */ -typedef union { - GhosttySgrUnknown unknown; - GhosttySgrUnderline underline; - GhosttyColorRgb underline_color; - GhosttyColorPaletteIndex underline_color_256; - GhosttyColorRgb direct_color_fg; - GhosttyColorRgb direct_color_bg; - GhosttyColorPaletteIndex bg_8; - GhosttyColorPaletteIndex fg_8; - GhosttyColorPaletteIndex bright_bg_8; - GhosttyColorPaletteIndex bright_fg_8; - GhosttyColorPaletteIndex bg_256; - GhosttyColorPaletteIndex fg_256; - uint64_t _padding[8]; -} GhosttySgrAttributeValue; - -/** - * SGR attribute (tagged union). - * - * A complete SGR attribute with both its type tag and associated value. - * Always check the tag field to determine which value union member is valid. - * - * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be - * identified by tag alone; the value union is not used for these and - * the memory in the value field is undefined. - * - * @ingroup sgr - */ -typedef struct { - GhosttySgrAttributeTag tag; - GhosttySgrAttributeValue value; -} GhosttySgrAttribute; - -/** - * Create a new SGR parser instance. - * - * Creates a new SGR (Select Graphic Rendition) parser using the provided - * allocator. The parser must be freed using ghostty_sgr_free() when - * no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or - * NULL to use the default allocator - * @param parser Pointer to store the created parser handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup sgr - */ -GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator, - GhosttySgrParser* parser); - -/** - * Free an SGR parser instance. - * - * Releases all resources associated with the SGR parser. After this call, - * the parser handle becomes invalid and must not be used. This includes - * any attributes previously returned by ghostty_sgr_next(). - * - * @param parser The parser handle to free (may be NULL) - * - * @ingroup sgr - */ -void ghostty_sgr_free(GhosttySgrParser parser); - -/** - * Reset an SGR parser instance to the beginning of the parameter list. - * - * Resets the parser's iteration state without clearing the parameters. - * After calling this, ghostty_sgr_next() will start from the beginning - * of the parameter list again. - * - * @param parser The parser handle to reset, must not be NULL - * - * @ingroup sgr - */ -void ghostty_sgr_reset(GhosttySgrParser parser); - -/** - * Set SGR parameters for parsing. - * - * Sets the SGR parameter list to parse. Parameters are the numeric values - * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}). - * - * The separators array optionally specifies the separator type for each - * parameter position. Each byte should be either ';' for semicolon or ':' - * for colon. This is needed for certain color formats that use colon - * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator - * values are treated as semicolons. The separators array must have the same - * length as the params array, if it is not NULL. - * - * If separators is NULL, all parameters are assumed to be semicolon-separated. - * - * This function makes an internal copy of the parameter and separator data, - * so the caller can safely free or modify the input arrays after this call. - * - * After calling this function, the parser is automatically reset and ready - * to iterate from the beginning. - * - * @param parser The parser handle, must not be NULL - * @param params Array of SGR parameter values - * @param separators Optional array of separator characters (';' or ':'), or - * NULL - * @param len Number of parameters (and separators if provided) - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup sgr - */ -GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser, - const uint16_t* params, - const char* separators, - size_t len); - -/** - * Get the next SGR attribute. - * - * Parses and returns the next attribute from the parameter list. - * Call this function repeatedly until it returns false to process - * all attributes in the sequence. - * - * @param parser The parser handle, must not be NULL - * @param attr Pointer to store the next attribute - * @return true if an attribute was returned, false if no more attributes - * - * @ingroup sgr - */ -bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr); - -/** - * Get the full parameter list from an unknown SGR attribute. - * - * This function retrieves the full parameter list that was provided to the - * parser when an unknown attribute was encountered. Primarily useful in - * WebAssembly environments where accessing struct fields directly is difficult. - * - * @param unknown The unknown attribute data - * @param ptr Pointer to store the pointer to the parameter array (may be NULL) - * @return The length of the full parameter array - * - * @ingroup sgr - */ -size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown, - const uint16_t** ptr); - -/** - * Get the partial parameter list from an unknown SGR attribute. - * - * This function retrieves the partial parameter list where parsing stopped - * when an unknown attribute was encountered. Primarily useful in WebAssembly - * environments where accessing struct fields directly is difficult. - * - * @param unknown The unknown attribute data - * @param ptr Pointer to store the pointer to the parameter array (may be NULL) - * @return The length of the partial parameter array - * - * @ingroup sgr - */ -size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown, - const uint16_t** ptr); - -/** - * Get the tag from an SGR attribute. - * - * This function extracts the tag that identifies which type of attribute - * this is. Primarily useful in WebAssembly environments where accessing - * struct fields directly is difficult. - * - * @param attr The SGR attribute - * @return The attribute tag - * - * @ingroup sgr - */ -GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr); - -/** - * Get the value from an SGR attribute. - * - * This function returns a pointer to the value union from an SGR attribute. Use - * the tag to determine which field of the union is valid. Primarily useful in - * WebAssembly environments where accessing struct fields directly is difficult. - * - * @param attr Pointer to the SGR attribute - * @return Pointer to the attribute value union - * - * @ingroup sgr - */ -GhosttySgrAttributeValue* ghostty_sgr_attribute_value( - GhosttySgrAttribute* attr); - -#ifdef __wasm__ -/** - * Allocate memory for an SGR attribute (WebAssembly only). - * - * This is a convenience function for WebAssembly environments to allocate - * memory for an SGR attribute structure that can be passed to ghostty_sgr_next. - * - * @return Pointer to the allocated attribute structure - * - * @ingroup wasm - */ -GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void); - -/** - * Free memory for an SGR attribute (WebAssembly only). - * - * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute. - * - * @param attr Pointer to the attribute structure to free - * - * @ingroup wasm - */ -void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr); -#endif - -#ifdef __cplusplus -} -#endif - -/** @} */ - -#endif /* GHOSTTY_VT_SGR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/wasm.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/wasm.h deleted file mode 100644 index 37a826326..000000000 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/wasm.h +++ /dev/null @@ -1,159 +0,0 @@ -/** - * @file wasm.h - * - * WebAssembly utility functions for libghostty-vt. - */ - -#ifndef GHOSTTY_VT_WASM_H -#define GHOSTTY_VT_WASM_H - -#ifdef __wasm__ - -#include -#include - -/** @defgroup wasm WebAssembly Utilities - * - * Convenience functions for allocating various types in WebAssembly builds. - * **These are only available the libghostty-vt wasm module.** - * - * Ghostty relies on pointers to various types for ABI compatibility, and - * creating those pointers in Wasm can be tedious. These functions provide - * a purely additive set of utilities that simplify memory management in - * Wasm environments without changing the core C library API. - * - * @note These functions always use the default allocator. If you need - * custom allocation strategies, you should allocate types manually using - * your custom allocator. This is a very rare use case in the WebAssembly - * world so these are optimized for simplicity. - * - * ## Example Usage - * - * Here's a simple example of using the Wasm utilities with the key encoder: - * - * @code - * const { exports } = wasmInstance; - * const view = new DataView(wasmMemory.buffer); - * - * // Create key encoder - * const encoderPtr = exports.ghostty_wasm_alloc_opaque(); - * exports.ghostty_key_encoder_new(null, encoderPtr); - * const encoder = view.getUint32(encoder, true); - * - * // Configure encoder with Kitty protocol flags - * const flagsPtr = exports.ghostty_wasm_alloc_u8(); - * view.setUint8(flagsPtr, 0x1F); - * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr); - * - * // Allocate output buffer and size pointer - * const bufferSize = 32; - * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize); - * const writtenPtr = exports.ghostty_wasm_alloc_usize(); - * - * // Encode the key event - * exports.ghostty_key_encoder_encode( - * encoder, eventPtr, bufPtr, bufferSize, writtenPtr - * ); - * - * // Read encoded output - * const bytesWritten = view.getUint32(writtenPtr, true); - * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten); - * @endcode - * - * @remark The code above is pretty ugly! This is the lowest level interface - * to the libghostty-vt Wasm module. In practice, this should be wrapped - * in a higher-level API that abstracts away all this. - * - * @{ - */ - -/** - * Allocate an opaque pointer. This can be used for any opaque pointer - * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc. - * - * @return Pointer to allocated opaque pointer, or NULL if allocation failed - * @ingroup wasm - */ -void** ghostty_wasm_alloc_opaque(void); - -/** - * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque(). - * - * @param ptr Pointer to free, or NULL (NULL is safely ignored) - * @ingroup wasm - */ -void ghostty_wasm_free_opaque(void **ptr); - -/** - * Allocate an array of uint8_t values. - * - * @param len Number of uint8_t elements to allocate - * @return Pointer to allocated array, or NULL if allocation failed - * @ingroup wasm - */ -uint8_t* ghostty_wasm_alloc_u8_array(size_t len); - -/** - * Free an array allocated by ghostty_wasm_alloc_u8_array(). - * - * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) - * @param len Length of the array (must match the length passed to alloc) - * @ingroup wasm - */ -void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len); - -/** - * Allocate an array of uint16_t values. - * - * @param len Number of uint16_t elements to allocate - * @return Pointer to allocated array, or NULL if allocation failed - * @ingroup wasm - */ -uint16_t* ghostty_wasm_alloc_u16_array(size_t len); - -/** - * Free an array allocated by ghostty_wasm_alloc_u16_array(). - * - * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) - * @param len Length of the array (must match the length passed to alloc) - * @ingroup wasm - */ -void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len); - -/** - * Allocate a single uint8_t value. - * - * @return Pointer to allocated uint8_t, or NULL if allocation failed - * @ingroup wasm - */ -uint8_t* ghostty_wasm_alloc_u8(void); - -/** - * Free a uint8_t allocated by ghostty_wasm_alloc_u8(). - * - * @param ptr Pointer to free, or NULL (NULL is safely ignored) - * @ingroup wasm - */ -void ghostty_wasm_free_u8(uint8_t *ptr); - -/** - * Allocate a single size_t value. - * - * @return Pointer to allocated size_t, or NULL if allocation failed - * @ingroup wasm - */ -size_t* ghostty_wasm_alloc_usize(void); - -/** - * Free a size_t allocated by ghostty_wasm_alloc_usize(). - * - * @param ptr Pointer to free, or NULL (NULL is safely ignored) - * @ingroup wasm - */ -void ghostty_wasm_free_usize(size_t *ptr); - -/** @} */ - -#endif /* __wasm__ */ - -#endif /* GHOSTTY_VT_WASM_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a index d35d909c3..d700ddcf1 100644 Binary files a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a and b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a differ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION b/apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION index 15420168e..92e5c8af3 100644 --- a/apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION @@ -1 +1 @@ -d36c3b8dffd0d756dd5e5f4933962f774a0e6753 +cf8edc23f3a6a87a96e41a90013e89e987d34980 diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index cee4d0427..f04db4467 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -459,7 +459,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { supports_selection_clipboard: false, wakeup_cb: { _ in }, action_cb: { _, _, _ in false }, - read_clipboard_cb: { _, _, _ in false }, + read_clipboard_cb: { _, _, _, _, _, _ in GHOSTTY_CLIPBOARD_READ_UNSUPPORTED }, confirm_read_clipboard_cb: { _, _, _, _ in }, write_clipboard_cb: { _, _, _, _, _ in }, close_surface_cb: { _, _ in } diff --git a/apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh index d2f1e19bc..4f6f95010 100755 --- a/apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh +++ b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh @@ -7,7 +7,7 @@ MODULE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" VENDOR_DIR="${MODULE_DIR}/Vendor/libghostty" GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${HOME}/ghostty}" -GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}" +GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.16.0}" GHOSTTY_ZIG="${GHOSTTY_ZIG:-}" log() { @@ -86,8 +86,8 @@ log "building GhosttyKit.xcframework" ) xcframework="${GHOSTTY_SOURCE_DIR}/macos/GhosttyKit.xcframework" -ios_archive="${xcframework}/ios-arm64/libghostty-fat.a" -sim_archive="${xcframework}/ios-arm64-simulator/libghostty-fat.a" +ios_archive="${xcframework}/ios-arm64/libghostty-internal.a" +sim_archive="${xcframework}/ios-arm64-simulator/libghostty-internal.a" [[ -f "${ios_archive}" ]] || die "missing built iOS archive: ${ios_archive}" [[ -f "${sim_archive}" ]] || die "missing built iOS simulator archive: ${sim_archive}" @@ -102,5 +102,8 @@ rsync -a --delete "${xcframework}/ios-arm64/Headers/" \ "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64/Headers/" rsync -a --delete "${xcframework}/ios-arm64-simulator/Headers/" \ "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64-simulator/Headers/" +sed -i '' -e 's/[[:space:]]*$//' \ + "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64/Headers/ghostty.h" \ + "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty.h" log "done" diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 57d8b4a2d..be1e96239 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -105,7 +105,6 @@ "expo-video": "~57.0.3", "expo-web-browser": "~57.0.2", "expo-widgets": "~57.0.15", - "punycode": "^2.3.1", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.3", diff --git a/apps/mobile/src/components/GlassSafeAreaView.tsx b/apps/mobile/src/components/GlassSafeAreaView.tsx deleted file mode 100644 index 16a5fc4a4..000000000 --- a/apps/mobile/src/components/GlassSafeAreaView.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { ReactNode } from "react"; -import { View, type StyleProp, type ViewStyle } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; - -import { GlassSurface } from "./GlassSurface"; - -export interface GlassSafeAreaViewProps { - readonly leftSlot?: ReactNode; - readonly centerSlot?: ReactNode; - readonly rightSlot?: ReactNode; - readonly style?: StyleProp; -} - -export function GlassSafeAreaView({ - leftSlot, - centerSlot, - rightSlot, - style, -}: GlassSafeAreaViewProps) { - const insets = useSafeAreaInsets(); - const headerPaddingTop = insets.top + 16; - - return ( - - - - {leftSlot} - {centerSlot} - {rightSlot} - - - - ); -} diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 2060bc17f..bdee41962 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -828,18 +828,6 @@ export function unregisterAgentAwarenessConnection(environmentId: EnvironmentId) removeAgentAwarenessConnection(environmentId); } -export function unregisterAllAgentAwarenessConnections(): void { - environmentConnections.clear(); - pushTokenSubscription?.remove(); - pushTokenSubscription = null; - appStateSubscription?.remove(); - appStateSubscription = null; - if (activeLiveActivityRegistrationRetry) { - clearTimeout(activeLiveActivityRegistrationRetry); - activeLiveActivityRegistrationRetry = null; - } -} - export function refreshAgentAwarenessRegistration(): Effect.Effect< void, never, diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts index eec1e3410..8c41d7484 100644 --- a/apps/mobile/src/features/cloud/managedRelayState.ts +++ b/apps/mobile/src/features/cloud/managedRelayState.ts @@ -4,10 +4,7 @@ import { managedRelaySessionAtom, readManagedRelaySnapshotState, } from "@t3tools/client-runtime/relay"; -import type { - RelayClientEnvironmentRecord, - RelayEnvironmentStatusResponse, -} from "@t3tools/contracts/relay"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect } from "react"; @@ -26,10 +23,6 @@ const EMPTY_ENVIRONMENTS_ATOM = Atom.make( AsyncResult.success>([]), ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environments:null")); -const EMPTY_ENVIRONMENT_STATUS_ATOM = Atom.make( - AsyncResult.initial(false), -).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environment-status:null")); - export function useManagedRelayEnvironments() { const session = useAtomValue(managedRelaySessionAtom); const accountId = session?.accountId ?? null; @@ -59,39 +52,6 @@ export function useManagedRelayEnvironments() { }; } -export function useManagedRelayEnvironmentStatus(environment: RelayClientEnvironmentRecord) { - const session = useAtomValue(managedRelaySessionAtom); - const accountId = session?.accountId ?? null; - const atom = accountId - ? managedRelayQueryManager.environmentStatusAtom({ accountId, environment }) - : EMPTY_ENVIRONMENT_STATUS_ATOM; - const result = useAtomValue(atom); - const snapshot = readManagedRelaySnapshotState(result); - useEffect(() => { - if (snapshot.error) { - console.error("[t3-cloud] Relay environment status failed", { - environmentId: environment.environmentId, - message: snapshot.error, - traceId: snapshot.errorTraceId, - }); - } - }, [environment.environmentId, snapshot.error, snapshot.errorTraceId]); - const refresh = useCallback(() => { - if (accountId) { - managedRelayQueryManager.refreshEnvironmentStatus(appAtomRegistry, { - accountId, - environment, - }); - } - }, [accountId, environment]); - - return { - ...snapshot, - accountId, - refresh, - }; -} - export function refreshManagedRelayEnvironments(): void { const session = appAtomRegistry.get(managedRelaySessionAtom); if (session) { diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts index 438b50a27..08ab53971 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts @@ -44,21 +44,6 @@ describe("resolveNativeReviewDiffView", () => { expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3ReviewDiffSurface"); }); - it("does not fall back to stale legacy native review diff view names", async () => { - globalThis.expo = { - getViewConfig: vi.fn().mockImplementation((moduleName: string) => { - if (moduleName === "T3ReviewDiffView") { - return { validAttributes: {}, directEventTypes: {} }; - } - return null; - }), - } as unknown as typeof globalThis.expo; - expoMocks.requireNativeView.mockReturnValue(nativeView); - const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface"); - expect(resolveNativeReviewDiffView()).toBeNull(); - expect(expoMocks.requireNativeView).not.toHaveBeenCalled(); - }); - it("returns null when the view manager cannot be required", async () => { setExpoViewConfigAvailable(); const cause = new Error("boom"); diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 052c89a7e..95177ed3e 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -12,7 +12,11 @@ import { ThreadId, } from "@t3tools/contracts"; import { videoMimeType } from "@t3tools/shared/video"; -import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; +import { + isWorkspaceBrowserPreviewPath, + isWorkspaceImagePreviewPath, + mediaMimeTypeFromExtension, +} from "@t3tools/shared/filePreview"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; @@ -56,8 +60,6 @@ import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { basename, - isBrowserPreviewFile, - isImagePreviewFile, isMarkdownPreviewFile, isSvgImagePreviewFile, isVideoPreviewFile, @@ -92,7 +94,9 @@ function normalizeRouteLine(value: string | null): number | null { function defaultViewMode(path: string | null): FileViewMode { return path !== null && - (isBrowserPreviewFile(path) || isImagePreviewFile(path) || isVideoPreviewFile(path)) + (isWorkspaceBrowserPreviewPath(path) || + isWorkspaceImagePreviewPath(path) || + isVideoPreviewFile(path)) ? "preview" : "source"; } @@ -114,8 +118,8 @@ function FileContent(props: { // Reopening a mutable host file must not reuse a poster from an earlier visit. const thumbnailInstanceId = useId(); const isMarkdown = isMarkdownPreviewFile(props.relativePath); - const isBrowserFile = isBrowserPreviewFile(props.relativePath); - const isImageFile = isImagePreviewFile(props.relativePath); + const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath); + const isImageFile = isWorkspaceImagePreviewPath(props.relativePath); if (isVideoPreviewFile(props.relativePath)) { return ( @@ -520,8 +524,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const previewKey = JSON.stringify([environmentId, cwd, relativePath, previewRevision]); const [fullScreenPreview, setFullScreenPreview] = useState(null); const isVideoFile = relativePath !== null && isVideoPreviewFile(relativePath); - const isBrowserFile = relativePath !== null && !isVideoFile && isBrowserPreviewFile(relativePath); - const isImageFile = relativePath !== null && !isVideoFile && isImagePreviewFile(relativePath); + const isBrowserFile = + relativePath !== null && !isVideoFile && isWorkspaceBrowserPreviewPath(relativePath); + const isImageFile = + relativePath !== null && !isVideoFile && isWorkspaceImagePreviewPath(relativePath); const canPreview = relativePath !== null && (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile || isVideoFile); diff --git a/apps/mobile/src/features/files/filePath.test.ts b/apps/mobile/src/features/files/filePath.test.ts index af0ace61f..eaa32df28 100644 --- a/apps/mobile/src/features/files/filePath.test.ts +++ b/apps/mobile/src/features/files/filePath.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - isBrowserPreviewFile, - isImagePreviewFile, - isSvgImagePreviewFile, - resolveWorkspaceRelativeFilePath, -} from "./filePath"; +import { isSvgImagePreviewFile, resolveWorkspaceRelativeFilePath } from "./filePath"; describe("resolveWorkspaceRelativeFilePath", () => { it("keeps normalized workspace-relative paths", () => { @@ -24,18 +19,12 @@ describe("resolveWorkspaceRelativeFilePath", () => { it("rejects paths outside the workspace", () => { expect(resolveWorkspaceRelativeFilePath("/repo", "/other/main.ts")).toBeNull(); expect(resolveWorkspaceRelativeFilePath("/repo", "../other/main.ts")).toBeNull(); + expect(resolveWorkspaceRelativeFilePath("/repo", "/repo/../outside.txt")).toBeNull(); expect(resolveWorkspaceRelativeFilePath(null, "/repo/main.ts")).toBeNull(); }); }); describe("file preview types", () => { - it("recognizes browser and image previews", () => { - expect(isBrowserPreviewFile("reports/summary.html")).toBe(true); - expect(isImagePreviewFile("assets/icon.png")).toBe(true); - expect(isImagePreviewFile("assets/diagram.SVG?raw=1")).toBe(true); - expect(isImagePreviewFile("src/image.ts")).toBe(false); - }); - it("identifies SVG images that need web rendering", () => { expect(isSvgImagePreviewFile("assets/diagram.svg#icon")).toBe(true); expect(isSvgImagePreviewFile("assets/photo.png")).toBe(false); diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index 12217aab7..b6f351cff 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -1,8 +1,4 @@ -import { - isWorkspaceBrowserPreviewPath, - isWorkspaceImagePreviewPath, - isWorkspaceVideoPreviewPath, -} from "@t3tools/shared/filePreview"; +import { isWorkspaceVideoPreviewPath } from "@t3tools/shared/filePreview"; export interface FileBreadcrumb { readonly label: string; @@ -14,7 +10,7 @@ function isWindowsAbsolutePath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); } -function isAbsolutePath(value: string): boolean { +export function isAbsolutePath(value: string): boolean { return value.startsWith("/") || isWindowsAbsolutePath(value); } @@ -85,15 +81,12 @@ export function resolveWorkspaceRelativeFilePath( return null; } - return normalizeRelativePath(normalizedTarget.slice(normalizedRoot.length + 1)); -} - -export function isBrowserPreviewFile(path: string): boolean { - return isWorkspaceBrowserPreviewPath(path); -} - -export function isImagePreviewFile(path: string): boolean { - return isWorkspaceImagePreviewPath(path); + const relativePath = normalizedTarget.slice(normalizedRoot.length + 1); + // `/repo/../x` starts with the root but escapes it. + if (relativePath.split("/").includes("..")) { + return null; + } + return normalizeRelativePath(relativePath); } export function isVideoPreviewFile(path: string): boolean { diff --git a/apps/mobile/src/features/files/preload-workspace-file.ts b/apps/mobile/src/features/files/preload-workspace-file.ts index 7df750883..a91e4f84b 100644 --- a/apps/mobile/src/features/files/preload-workspace-file.ts +++ b/apps/mobile/src/features/files/preload-workspace-file.ts @@ -1,9 +1,13 @@ import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; +import { + isWorkspaceBrowserPreviewPath, + isWorkspaceImagePreviewPath, +} from "@t3tools/shared/filePreview"; import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; -import { isBrowserPreviewFile, isImagePreviewFile, isVideoPreviewFile } from "./filePath"; +import { isVideoPreviewFile } from "./filePath"; import { prepareSourceFileDocument } from "./source-file-document"; import { sourceHighlightAtom } from "./sourceHighlightingState"; import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter"; @@ -26,8 +30,8 @@ export function preloadWorkspaceFileContents(input: { readonly theme: ReviewDiffTheme; }): void { if ( - isBrowserPreviewFile(input.relativePath) || - isImagePreviewFile(input.relativePath) || + isWorkspaceBrowserPreviewPath(input.relativePath) || + isWorkspaceImagePreviewPath(input.relativePath) || isVideoPreviewFile(input.relativePath) ) { return; diff --git a/apps/mobile/src/features/review/ReviewHighlighterProvider.tsx b/apps/mobile/src/features/review/ReviewHighlighterProvider.tsx index 150584aed..e35367b74 100644 --- a/apps/mobile/src/features/review/ReviewHighlighterProvider.tsx +++ b/apps/mobile/src/features/review/ReviewHighlighterProvider.tsx @@ -1,4 +1,4 @@ -import { createContext, type ReactNode, useContext, useMemo } from "react"; +import { createContext, type ReactNode, useMemo } from "react"; import { type ReviewHighlighterState, useReviewHighlighterState } from "./reviewHighlighterState"; @@ -18,7 +18,3 @@ export function ReviewHighlighterProvider(props: { readonly children: ReactNode ); } - -export function useReviewHighlighterStatus(): ReviewHighlighterState { - return useContext(ReviewHighlighterContext); -} diff --git a/apps/mobile/src/features/review/diffParser.ts b/apps/mobile/src/features/review/diffParser.ts deleted file mode 100644 index 76e8872f8..000000000 --- a/apps/mobile/src/features/review/diffParser.ts +++ /dev/null @@ -1,158 +0,0 @@ -export type ParsedDiffLineType = "context" | "add" | "delete" | "meta" | "hunk"; - -export interface ParsedDiffLine { - readonly id: string; - readonly type: ParsedDiffLineType; - readonly oldLine: number | null; - readonly newLine: number | null; - readonly content: string; -} - -export interface ParsedDiffFile { - readonly id: string; - readonly oldPath: string | null; - readonly newPath: string | null; - readonly lines: ReadonlyArray; -} - -function parseHunkStart( - line: string, -): { readonly oldLine: number; readonly newLine: number } | null { - const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); - if (!match) { - return null; - } - - return { - oldLine: Number.parseInt(match[1] ?? "0", 10), - newLine: Number.parseInt(match[2] ?? "0", 10), - }; -} - -function parseDiffPath(line: string, prefix: "--- " | "+++ "): string | null { - if (!line.startsWith(prefix)) { - return null; - } - const raw = line.slice(prefix.length).trim(); - if (raw === "/dev/null") { - return null; - } - return raw.replace(/^[ab]\//, ""); -} - -export function parseUnifiedDiff(diff: string): ReadonlyArray { - const files: ParsedDiffFile[] = []; - let current: { - oldPath: string | null; - newPath: string | null; - lines: ParsedDiffLine[]; - } | null = null; - let oldLine: number | null = null; - let newLine: number | null = null; - - const pushCurrent = () => { - if (!current) { - return; - } - files.push({ - id: `${current.oldPath ?? "null"}:${current.newPath ?? "null"}:${files.length}`, - oldPath: current.oldPath, - newPath: current.newPath, - lines: current.lines, - }); - }; - - for (const rawLine of diff.replace(/\r\n/g, "\n").split("\n")) { - if (rawLine.startsWith("diff --git ")) { - pushCurrent(); - const match = rawLine.match(/^diff --git a\/(.+) b\/(.+)$/); - current = { - oldPath: match?.[1] ?? null, - newPath: match?.[2] ?? null, - lines: [], - }; - oldLine = null; - newLine = null; - continue; - } - - if (!current) { - if (rawLine.trim().length === 0) { - continue; - } - current = { oldPath: null, newPath: null, lines: [] }; - } - - const oldPath = parseDiffPath(rawLine, "--- "); - if (oldPath !== null || rawLine === "--- /dev/null") { - current.oldPath = oldPath; - continue; - } - - const newPath = parseDiffPath(rawLine, "+++ "); - if (newPath !== null || rawLine === "+++ /dev/null") { - current.newPath = newPath; - continue; - } - - const hunk = parseHunkStart(rawLine); - if (hunk) { - oldLine = hunk.oldLine; - newLine = hunk.newLine; - current.lines.push({ - id: `${current.lines.length}:hunk`, - type: "hunk", - oldLine: null, - newLine: null, - content: rawLine, - }); - continue; - } - - if (oldLine === null || newLine === null) { - current.lines.push({ - id: `${current.lines.length}:meta`, - type: "meta", - oldLine: null, - newLine: null, - content: rawLine, - }); - continue; - } - - const marker = rawLine[0]; - const content = rawLine.length > 0 ? rawLine.slice(1) : ""; - if (marker === "+") { - current.lines.push({ - id: `${current.lines.length}:add:${newLine}`, - type: "add", - oldLine: null, - newLine, - content, - }); - newLine += 1; - } else if (marker === "-") { - current.lines.push({ - id: `${current.lines.length}:delete:${oldLine}`, - type: "delete", - oldLine, - newLine: null, - content, - }); - oldLine += 1; - } else { - current.lines.push({ - id: `${current.lines.length}:context:${oldLine}:${newLine}`, - type: "context", - oldLine, - newLine, - content: marker === " " ? content : rawLine, - }); - oldLine += 1; - newLine += 1; - } - } - - pushCurrent(); - return files.filter((file) => file.lines.length > 0 || file.oldPath || file.newPath); -} diff --git a/apps/mobile/src/features/review/reviewCommentSelection.ts b/apps/mobile/src/features/review/reviewCommentSelection.ts index 5e6e1c368..8ec9bbb43 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.ts @@ -84,16 +84,6 @@ export function getReviewUnifiedLineNumber(line: ReviewRenderableLineRow): numbe return line.newLineNumber ?? line.oldLineNumber; } -export function formatReviewLineLabel(line: ReviewRenderableLineRow): string { - if (line.newLineNumber !== null) { - return `new line ${line.newLineNumber}`; - } - if (line.oldLineNumber !== null) { - return `old line ${line.oldLineNumber}`; - } - return "file"; -} - export function getReviewChangeMarker(change: ReviewRenderableLineRow["change"]): string { if (change === "add") return "+"; if (change === "delete") return "-"; diff --git a/apps/mobile/src/features/review/reviewState.ts b/apps/mobile/src/features/review/reviewState.ts index 3fce8131d..a86d53c35 100644 --- a/apps/mobile/src/features/review/reviewState.ts +++ b/apps/mobile/src/features/review/reviewState.ts @@ -242,20 +242,6 @@ export function updateReviewExpandedFileIds( }); } -export function updateReviewRevealedLargeFileIds( - threadKey: string, - sectionId: string, - update: (current: ReadonlyArray | undefined) => ReadonlyArray | undefined, -): void { - const atom = reviewRevealedLargeFileIdsByThreadKeyAtom(threadKey); - const current = appAtomRegistry.get(atom); - const nextValue = update(current[sectionId]); - appAtomRegistry.set(atom, { - ...current, - [sectionId]: nextValue, - }); -} - export function updateReviewViewedFileIds( threadKey: string, sectionId: string, diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 008a07619..c684a6686 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -814,22 +814,6 @@ function storeResolvedHighlightedFile(cacheKey: string, highlighted: ReviewHighl } } -export function clearReviewHighlightFileCache(): void { - highlightCache.clear(); - resolvedHighlightCache.clear(); -} - -export function getCachedHighlightedReviewFile( - file: ReviewRenderableFile, - theme: ReviewDiffTheme, -): ReviewHighlightedFile | null { - if (REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - return null; - } - - return resolvedHighlightCache.get(getHighlightCacheKey(file, theme)) ?? null; -} - export async function highlightReviewFile( file: ReviewRenderableFile, theme: ReviewDiffTheme, diff --git a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx deleted file mode 100644 index e3fa761d6..000000000 --- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; -import { SymbolView } from "../../components/AppSymbol"; -import { memo, useCallback, useEffect, useMemo, useRef } from "react"; -import { Pressable, View } from "react-native"; - -import { AppText as Text } from "../../components/AppText"; -import { terminalEnvironment } from "../../state/terminal"; -import { useAtomCommand } from "../../state/use-atom-command"; -import { useAttachedTerminalSession } from "../../state/use-terminal-session"; -import { TerminalSurface } from "./NativeTerminalSurface"; -import { hasNativeTerminalSurface } from "./nativeTerminalModule"; -import { - buildThreadTerminalAttachInput, - type TerminalGridSize, - type ThreadTerminalSubscriptionIdentity, -} from "./threadTerminalPanelModel"; - -interface ThreadTerminalPanelProps { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - readonly cwd: string; - readonly worktreePath: string | null; - readonly visible: boolean; - readonly onClose: () => void; -} - -const DEFAULT_TERMINAL_COLS = 80; -const DEFAULT_TERMINAL_ROWS = 24; - -export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( - props: ThreadTerminalPanelProps, -) { - const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); - const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); - const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); - const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); - const nativeTerminalAvailable = hasNativeTerminalSurface(); - const terminalId = DEFAULT_TERMINAL_ID; - const lastGridSizeRef = useRef({ - cols: DEFAULT_TERMINAL_COLS, - rows: DEFAULT_TERMINAL_ROWS, - }); - const subscriptionIdentity = useMemo( - () => ({ - environmentId: props.environmentId, - threadId: props.threadId, - terminalId, - cwd: props.cwd, - worktreePath: props.worktreePath, - }), - [props.cwd, props.environmentId, props.threadId, props.worktreePath, terminalId], - ); - const attachInput = useMemo( - () => - props.visible - ? buildThreadTerminalAttachInput(subscriptionIdentity, lastGridSizeRef.current) - : null, - [props.visible, subscriptionIdentity], - ); - const terminal = useAttachedTerminalSession({ - environmentId: props.environmentId, - terminal: attachInput, - }); - - const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`; - const isRunning = terminal.status === "running" || terminal.status === "starting"; - - // Close the session and dismiss the panel when the process ends while - // attached (e.g. typing `exit`), mirroring the web drawer's - // onSessionExited flow. - const runningTerminalKeyRef = useRef(null); - const reopenedStaleTerminalKeyRef = useRef(null); - - // Attach subscriptions are cached with an idle TTL; reopening the panel - // after its session ended reuses the stale stream without a new attach - // RPC. Issue an explicit open so the server respawns the session and its - // snapshot flows into the live subscription. - useEffect(() => { - if (isRunning) { - reopenedStaleTerminalKeyRef.current = null; - return; - } - if ( - attachInput === null || - (terminal.status !== "closed" && terminal.status !== "exited") || - terminal.version === 0 || - runningTerminalKeyRef.current === terminalKey || - reopenedStaleTerminalKeyRef.current === terminalKey - ) { - return; - } - reopenedStaleTerminalKeyRef.current = terminalKey; - void openTerminal({ - environmentId: props.environmentId, - input: { - threadId: props.threadId, - terminalId, - cwd: props.cwd, - worktreePath: props.worktreePath, - cols: lastGridSizeRef.current.cols, - rows: lastGridSizeRef.current.rows, - }, - }).then((result) => { - // Release the guard on failure so a later render can retry the respawn. - if (result._tag === "Failure" && reopenedStaleTerminalKeyRef.current === terminalKey) { - reopenedStaleTerminalKeyRef.current = null; - } - }); - }, [ - attachInput, - isRunning, - openTerminal, - props.cwd, - props.environmentId, - props.threadId, - props.worktreePath, - terminal.status, - terminal.version, - terminalId, - terminalKey, - ]); - - useEffect(() => { - // Forget both markers while hidden: if the process ends while the panel - // is unobserved (or was just auto-closed), the next show must take the - // stale-reopen path instead of treating it as a live exit or skipping - // the respawn. - if (attachInput === null) { - runningTerminalKeyRef.current = null; - reopenedStaleTerminalKeyRef.current = null; - return; - } - if (isRunning) { - runningTerminalKeyRef.current = terminalKey; - return; - } - // The web drawer treats both exited and closed as session end. - const sessionEnded = terminal.status === "exited" || terminal.status === "closed"; - if (!sessionEnded || runningTerminalKeyRef.current !== terminalKey) { - return; - } - runningTerminalKeyRef.current = null; - // Mark this key handled so the stale-attach effect doesn't respawn the - // session the user just ended. - reopenedStaleTerminalKeyRef.current = terminalKey; - void closeTerminal({ - environmentId: props.environmentId, - input: { - threadId: props.threadId, - terminalId, - }, - }); - props.onClose(); - }, [attachInput, closeTerminal, isRunning, props, terminal.status, terminalId, terminalKey]); - - const sendResize = useCallback( - (size: TerminalGridSize) => { - void resizeTerminal({ - environmentId: props.environmentId, - input: { - threadId: props.threadId, - terminalId, - cols: size.cols, - rows: size.rows, - }, - }); - }, - [props.environmentId, props.threadId, resizeTerminal, terminalId], - ); - - useEffect(() => { - if (isRunning) { - sendResize(lastGridSizeRef.current); - } - }, [isRunning, sendResize]); - - const handleInput = useCallback( - (data: string) => { - if (!isRunning) { - return; - } - - void writeTerminal({ - environmentId: props.environmentId, - input: { - threadId: props.threadId, - terminalId, - data, - }, - }); - }, - [isRunning, props.environmentId, props.threadId, terminalId, writeTerminal], - ); - - const handleResize = useCallback( - (size: TerminalGridSize) => { - const previousSize = lastGridSizeRef.current; - if (size.cols === previousSize.cols && size.rows === previousSize.rows) { - return; - } - - lastGridSizeRef.current = size; - if (!isRunning) { - return; - } - - sendResize(size); - }, - [isRunning, sendResize], - ); - - if (!props.visible) { - return null; - } - - return ( - - - - - Terminal - - - {nativeTerminalAvailable ? "Native Ghostty surface" : "Text fallback active"} - - - - {terminal.error ? ( - - {terminal.error} - - ) : null} - - - - - - - - ); -}); diff --git a/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts b/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts deleted file mode 100644 index 07ef46a7b..000000000 --- a/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { EnvironmentId, TerminalAttachInput } from "@t3tools/contracts"; - -export interface ThreadTerminalSubscriptionIdentity { - readonly environmentId: EnvironmentId; - readonly threadId: TerminalAttachInput["threadId"]; - readonly terminalId: TerminalAttachInput["terminalId"]; - readonly cwd: string; - readonly worktreePath: string | null; -} - -export interface TerminalGridSize { - readonly cols: number; - readonly rows: number; -} - -export function buildThreadTerminalAttachInput( - identity: ThreadTerminalSubscriptionIdentity, - gridSize: TerminalGridSize, -): TerminalAttachInput { - return { - threadId: identity.threadId, - terminalId: identity.terminalId, - cwd: identity.cwd, - worktreePath: identity.worktreePath, - cols: gridSize.cols, - rows: gridSize.rows, - }; -} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 25e43ae68..bfd123155 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -289,6 +289,11 @@ export const COMPOSER_LAYOUT_TRANSITION = ? undefined : LinearTransition.duration(COMPOSER_TRANSITION_DURATION_MS).reduceMotion(ReduceMotion.System); +const COMPOSER_ATTACHMENT_ENTERING = + Platform.OS === "android" + ? FadeIn.duration(160) + : FadeIn.delay(COMPOSER_TRANSITION_DURATION_MS).duration(160).reduceMotion(ReduceMotion.System); + const AnimatedGlassSurface = Animated.createAnimatedComponent(GlassSurface); export function ComposerSurface(props: { @@ -1713,10 +1718,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onPickFiles={props.onPickDraftFiles} /> ) : null} - {isExpanded ? ( + {isExpanded && props.draftAttachments.length > 0 ? ( 0 ? "px-[14px] pb-2.5" : undefined} - entering={FadeIn.duration(160)} + className="px-[14px] pb-2.5" + entering={COMPOSER_ATTACHMENT_ENTERING} exiting={FadeOut.duration(120)} layout={COMPOSER_LAYOUT_TRANSITION} > diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 5a3831086..dbe235877 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -92,6 +92,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, + type MarkdownFileContextMenu, type MarkdownImageRenderer, type NativeMarkdownTextStyle, type SelectableMarkdownSkill, @@ -170,8 +171,13 @@ import { usePreparedConnection } from "../../state/session"; import * as Option from "effect/Option"; import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; +import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { + // Native iOS blockquotes and adjacent selectable text are separate layout + // chunks. Giving their shrink-to-fit bubble a definite width keeps both + // chunks measured against the width at which UIKit draws them. + includeBlockquotes: Platform.OS === "ios", includeOrderedLists: Platform.OS === "android", } as const; @@ -874,10 +880,17 @@ function ArtifactTemplateCard(props: { ); } +/** Tap opens a link; long-press on a native file chip shows its menu. Built once per feed. */ +interface MarkdownLinkHandlers { + readonly onLinkPress: (href: string) => void; + readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; + readonly onFileContextMenuAction: (href: string, actionId: string) => void; +} + const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly markdown: string; readonly markdownStyles: MarkdownStyleSet; - readonly onLinkPress: (href: string) => void; + readonly linkHandlers: MarkdownLinkHandlers; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; @@ -906,7 +919,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { markdown={markdown} skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} - onLinkPress={props.onLinkPress} + {...props.linkHandlers} renderImage={props.renderImage} /> ) : ( @@ -1474,7 +1487,7 @@ function renderFeedEntry( readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; - readonly onMarkdownLinkPress: (href: string) => void; + readonly markdownLinkHandlers: MarkdownLinkHandlers; readonly renderMarkdownImage: MarkdownImageRenderer; readonly renderViewedImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; @@ -1574,7 +1587,7 @@ function renderFeedEntry( markdownStyles={styles} reviewCommentColors={props.reviewCommentColors} skills={props.skills} - onLinkPress={props.onMarkdownLinkPress} + linkHandlers={props.markdownLinkHandlers} renderImage={props.renderMarkdownImage} /> ) : null} @@ -1655,7 +1668,7 @@ function renderFeedEntry( ; - readonly onLinkPress: (href: string) => void; + readonly linkHandlers: MarkdownLinkHandlers; readonly renderImage: MarkdownImageRenderer; }) { const segments = parseReviewCommentMessageSegments(props.text); @@ -1740,7 +1753,7 @@ function UserMessageContent(props: { skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks - onLinkPress={props.onLinkPress} + {...props.linkHandlers} renderImage={props.renderImage} /> ); @@ -1782,7 +1795,7 @@ function UserMessageContent(props: { skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks - onLinkPress={props.onLinkPress} + {...props.linkHandlers} renderImage={props.renderImage} /> ) : ( @@ -2150,6 +2163,31 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, [props.environmentId, props.threadId, props.workspaceRoot, navigation], ); + const markdownLinkHandlers = useMemo( + () => ({ + onLinkPress: onMarkdownLinkPress, + fileContextMenu: (href) => { + const target = resolveFileChipTarget(href, props.workspaceRoot); + return target ? fileChipMenu(target) : undefined; + }, + onFileContextMenuAction: (href, actionId) => { + const target = resolveFileChipTarget(href, props.workspaceRoot); + if (!target) return; + switch (actionId as FileChipAction) { + case "copy-full-path": + if (target.fullPath) copyTextWithHaptic(target.fullPath); + return; + case "copy-relative-path": + if (target.relativePath) copyTextWithHaptic(target.relativePath); + return; + case "open-file": + onMarkdownLinkPress(href); + return; + } + }, + }), + [onMarkdownLinkPress, props.workspaceRoot], + ); const renderMarkdownImage = useCallback( (image) => { const media = resolveMarkdownMediaPreview(image.href, { @@ -2662,7 +2700,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleTurnFold, onPressPreview, onPressVideo, - onMarkdownLinkPress, + markdownLinkHandlers, renderMarkdownImage, renderViewedImage, iconSubtleColor, @@ -2694,7 +2732,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentBubbleWidth, userBubbleMaxWidth, onCopyWorkRow, - onMarkdownLinkPress, + markdownLinkHandlers, onPressPreview, onPressVideo, onToggleTurnFold, diff --git a/apps/mobile/src/features/threads/fileChipMenu.test.ts b/apps/mobile/src/features/threads/fileChipMenu.test.ts new file mode 100644 index 000000000..eb9bad3a4 --- /dev/null +++ b/apps/mobile/src/features/threads/fileChipMenu.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { fileChipMenu, resolveFileChipTarget } from "./fileChipMenu"; + +describe("resolveFileChipTarget", () => { + it("resolves a workspace-relative link to both paths", () => { + expect(resolveFileChipTarget("src/app.ts:12", "/repo")).toEqual({ + fullPath: "/repo/src/app.ts", + relativePath: "src/app.ts", + }); + }); + + it("keeps only the full path for a host file outside the workspace", () => { + expect(resolveFileChipTarget("/tmp/report.md", "/repo")).toEqual({ + fullPath: "/tmp/report.md", + }); + }); + + it("keeps only the relative path when the workspace root is unknown", () => { + expect(resolveFileChipTarget("src/app.ts", null)).toEqual({ relativePath: "src/app.ts" }); + }); + + it("ignores links that are not files or cannot be opened", () => { + expect(resolveFileChipTarget("https://example.com/app.ts", "/repo")).toBeNull(); + expect(resolveFileChipTarget("~/report.md", "/repo")).toBeNull(); + expect(resolveFileChipTarget("../other/file.ts", "/repo")).toBeNull(); + }); +}); + +describe("fileChipMenu", () => { + it("offers only the copies the target can satisfy", () => { + expect(fileChipMenu({ fullPath: "/tmp/report.md" })).toEqual({ + title: "/tmp/report.md", + actions: [ + { id: "copy-full-path", title: "Copy full path" }, + { id: "open-file", title: "Open in file viewer" }, + ], + }); + expect(fileChipMenu({ relativePath: "src/app.ts" }).actions.map(({ id }) => id)).toEqual([ + "copy-relative-path", + "open-file", + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts new file mode 100644 index 000000000..3630a62b3 --- /dev/null +++ b/apps/mobile/src/features/threads/fileChipMenu.ts @@ -0,0 +1,49 @@ +import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; +import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types"; + +import { + isAbsolutePath, + resolveWorkspaceFilePath, + resolveWorkspaceRelativeFilePath, +} from "../files/filePath"; + +export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file"; + +export interface FileChipTarget { + /** The host path, when the link is absolute or the workspace root is known. */ + readonly fullPath?: string; + /** The path inside the workspace, when the link resolves there. */ + readonly relativePath?: string; +} + +/** Null when the link is not a file or resolves nowhere the feed can open, such as `~/x` or `../x`. */ +export function resolveFileChipTarget( + href: string, + workspaceRoot: string | null | undefined, +): FileChipTarget | null { + const presentation = resolveMarkdownLinkPresentation(href); + if (presentation.kind !== "file") return null; + const relativePath = resolveWorkspaceRelativeFilePath(workspaceRoot, presentation.path); + const fullPath = isAbsolutePath(presentation.path) + ? presentation.path + : workspaceRoot && relativePath + ? resolveWorkspaceFilePath(workspaceRoot, relativePath) + : undefined; + if (!fullPath && !relativePath) return null; + return { + ...(fullPath ? { fullPath } : {}), + ...(relativePath ? { relativePath } : {}), + }; +} + +/** The same actions the web file chip offers on right-click. Opening is what a tap does. */ +export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { + return { + title: target.fullPath ?? target.relativePath ?? "", + actions: [ + ...(target.fullPath ? [{ id: "copy-full-path", title: "Copy full path" }] : []), + ...(target.relativePath ? [{ id: "copy-relative-path", title: "Copy relative path" }] : []), + { id: "open-file", title: "Open in file viewer" }, + ], + }; +} diff --git a/apps/mobile/src/features/threads/threadPresentation.test.ts b/apps/mobile/src/features/threads/threadPresentation.test.ts index 6df3acf66..50ad49b77 100644 --- a/apps/mobile/src/features/threads/threadPresentation.test.ts +++ b/apps/mobile/src/features/threads/threadPresentation.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { resolveThreadStatus, THREAD_STATUS_NEUTRAL_ICON } from "./threadPresentation"; +import { resolveThreadStatus } from "./threadPresentation"; const baseThread = { interactionMode: "default", @@ -64,11 +64,4 @@ describe("resolveThreadStatus", () => { }); }, ); - - it("retains upstream neutral icon metadata", () => { - expect(THREAD_STATUS_NEUTRAL_ICON).toEqual({ - iconColor: "#8e8e93", - iconBackground: "rgba(142,142,147,0.22)", - }); - }); }); diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index b91c7152a..59cf108a0 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -2,11 +2,6 @@ import type { StatusTone } from "../../components/StatusPill"; import type { OrchestrationLatestTurn, OrchestrationSession } from "@t3tools/contracts"; import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -export function threadSortValue(thread: EnvironmentThreadShell): number { - const candidate = Date.parse(thread.updatedAt ?? thread.createdAt); - return Number.isNaN(candidate) ? 0 : candidate; -} - export type ThreadStatusKind = | "pending-approval" | "awaiting-input" @@ -25,12 +20,6 @@ export interface ThreadStatusPresentation extends StatusTone { readonly pulse: boolean; } -/** Neutral icon colors for threads with no actionable status. */ -export const THREAD_STATUS_NEUTRAL_ICON = { - iconColor: "#8e8e93", - iconBackground: "rgba(142,142,147,0.22)", -} as const; - function isLatestTurnSettled( latestTurn: OrchestrationLatestTurn | null, session: OrchestrationSession | null, diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 22e792673..1f286eeee 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -6,9 +6,7 @@ import { import { detectComposerTrigger } from "@t3tools/shared/composerTrigger"; import { describe, expect, it, vi } from "vite-plus/test"; -// The hook's data source pulls in the React Native query stack; the pure -// builders under test never touch it. -vi.mock("../../state/use-composer-path-search", () => ({ +vi.mock("../../state/queries", () => ({ useComposerPathSearch: () => ({ entries: [], isPending: false }), })); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 8584f9803..f4302d321 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -2,6 +2,7 @@ import { dedupeProviderSkillsByName, getProviderSkillsForSlashMenu, getProviderSlashCommandsForSlashMenu, + isProviderSkillUserInvocable, } from "@t3tools/client-runtime/providerSkills"; import type { ComposerPathSearchEntry } from "@t3tools/client-runtime/state/threads"; import { @@ -29,7 +30,7 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ComposerEditorSelection } from "../../components/ComposerEditor"; -import { useComposerPathSearch } from "../../state/use-composer-path-search"; +import { useComposerPathSearch } from "../../state/queries"; import type { ComposerCommandItem } from "./ComposerCommandPopover"; import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; @@ -125,11 +126,15 @@ export function buildComposerCommandItems({ true, ); + // A provider expands a slash command only when it opens the whole message; + // elsewhere it arrives as literal text. `rangeStart` is the line start, so + // a `/foo` typed on a later line must not offer provider commands. const providerCommands: ComposerCommandItem[] = []; - for (const cmd of getProviderSlashCommandsForSlashMenu( - providerSlashCommands, - slashMenuSkills, - )) { + const expandableCommands = + trigger.rangeStart === 0 + ? getProviderSlashCommandsForSlashMenu(providerSlashCommands, slashMenuSkills) + : []; + for (const cmd of expandableCommands) { if (!cmd.name.toLowerCase().includes(q)) continue; // Codex `/feedback` uploads an existing thread's session and logs, so it // has nothing to send before the thread exists. @@ -160,7 +165,7 @@ export function buildComposerCommandItems({ if (trigger.kind === "skill") { const enabledSkills = dedupeProviderSkillsByName( - (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled), + (selectedProviderStatus?.skills ?? []).filter(isProviderSkillUserInvocable), ); const normalizedQuery = normalizeSearchQuery(trigger.query, { trimLeadingPattern: /^\$+/, diff --git a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts index 25ec4ff0e..61c4fb65c 100644 --- a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts +++ b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts @@ -9,10 +9,6 @@ import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode"; * Keep the legacy composer mode hidden until the preference has loaded and is * explicitly enabled. */ -export function useLegacyPlanModeEnabled(): boolean { - return useLegacyPlanModeState().enabled; -} - export function useLegacyPlanModeState(): { readonly enabled: boolean; readonly loaded: boolean } { const preferences = useAtomValue(mobilePreferencesAtom); const loaded = AsyncResult.isSuccess(preferences); diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts index d45993364..8ffab4e04 100644 --- a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts +++ b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts @@ -14,9 +14,9 @@ export function useThreadListV2ShelfPreferences() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const loaded = AsyncResult.isSuccess(preferencesResult); const snoozedShelfExpanded = - loaded && preferencesResult.value.threadListV2SnoozedShelfExpanded === true; + loaded && preferencesResult.value.threadListSnoozedShelfExpanded === true; const settledShelfExpanded = - !loaded || preferencesResult.value.threadListV2SettledShelfExpanded !== false; + loaded && preferencesResult.value.threadListSettledShelfExpanded === true; const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded); const settledShelfExpandedRef = useRef(settledShelfExpanded); snoozedShelfExpandedRef.current = snoozedShelfExpanded; @@ -26,13 +26,13 @@ export function useThreadListV2ShelfPreferences() { if (!loaded) return; const expanded = !snoozedShelfExpandedRef.current; snoozedShelfExpandedRef.current = expanded; - savePreferences({ threadListV2SnoozedShelfExpanded: expanded }); + savePreferences({ threadListSnoozedShelfExpanded: expanded }); }, [loaded, savePreferences]); const toggleSettledShelf = useCallback(() => { if (!loaded) return; const expanded = !settledShelfExpandedRef.current; settledShelfExpandedRef.current = expanded; - savePreferences({ threadListV2SettledShelfExpanded: expanded }); + savePreferences({ threadListSettledShelfExpanded: expanded }); }, [loaded, savePreferences]); return { diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index a1c7960a5..15e385067 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -213,33 +213,35 @@ describe("mobile connection storage", () => { expect(fallback.updatedAt).toEqual(expect.any(Number)); }); - it("persists Thread List v2 shelf expansion preferences", async () => { + it("persists thread list shelf expansion preferences", async () => { await expect( savePreferencesPatch({ - threadListV2SettledShelfExpanded: false, - threadListV2SnoozedShelfExpanded: true, + threadListSettledShelfExpanded: false, + threadListSnoozedShelfExpanded: true, }), ).resolves.toEqual({ - threadListV2SettledShelfExpanded: false, - threadListV2SnoozedShelfExpanded: true, + threadListSettledShelfExpanded: false, + threadListSnoozedShelfExpanded: true, }); await expect(loadPreferences()).resolves.toEqual({ - threadListV2SettledShelfExpanded: false, - threadListV2SnoozedShelfExpanded: true, + threadListSettledShelfExpanded: false, + threadListSnoozedShelfExpanded: true, }); expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ - threadListV2SettledShelfExpanded: false, - threadListV2SnoozedShelfExpanded: true, + threadListSettledShelfExpanded: false, + threadListSnoozedShelfExpanded: true, }); }); - it("ignores invalid Thread List v2 shelf expansion preference types", async () => { + it("drops legacy and invalid thread list shelf expansion preferences", async () => { mocks.setPreferencesJson( JSON.stringify({ baseFontSize: 17, - threadListV2SettledShelfExpanded: "false", - threadListV2SnoozedShelfExpanded: 1, + threadListV2SettledShelfExpanded: true, + threadListV2SnoozedShelfExpanded: true, + threadListSettledShelfExpanded: "false", + threadListSnoozedShelfExpanded: 1, }), 10, ); diff --git a/apps/mobile/src/lib/wideMarkdownBlocks.test.ts b/apps/mobile/src/lib/wideMarkdownBlocks.test.ts index b0af9434c..4f745aed8 100644 --- a/apps/mobile/src/lib/wideMarkdownBlocks.test.ts +++ b/apps/mobile/src/lib/wideMarkdownBlocks.test.ts @@ -17,6 +17,20 @@ describe("hasWideMarkdownBlock", () => { expect(hasWideMarkdownBlock(" ```\ncode\n```")).toBe(true); }); + it("detects indented code blocks", () => { + const prompt = 'before\n\n def search(x):\n return x\n\n"""\n\nafter'; + expect(hasWideMarkdownBlock(prompt)).toBe(true); + expect(hasWideMarkdownBlock("before\n\n\treturn x\n\nafter")).toBe(true); + expect(hasWideMarkdownBlock("before\n\n \treturn x\n\nafter")).toBe(true); + expect(hasWideMarkdownBlock("> return x")).toBe(true); + expect(hasWideMarkdownBlock("> > \treturn x")).toBe(true); + expect(hasWideMarkdownBlock(" 1. indented code")).toBe(true); + expect(hasWideMarkdownBlock(" - code-like bullet")).toBe(true); + expect(hasWideMarkdownBlock("before\n not code\nafter")).toBe(false); + expect(hasWideMarkdownBlock("before\n \nafter")).toBe(false); + expect(hasWideMarkdownBlock("before\n \t\nafter")).toBe(false); + }); + it("detects top-level and blockquoted ordered-list markers", () => { expect(hasWideMarkdownBlock("1. One\n2. Two\n3. Three\n4. Four\n5. Five")).toBe(true); expect(hasWideMarkdownBlock("before\n3) Three")).toBe(true); @@ -24,11 +38,9 @@ describe("hasWideMarkdownBlock", () => { expect(hasWideMarkdownBlock("> > 3) Three")).toBe(true); }); - it("detects nested ordered lists without treating indented code as a list", () => { + it("detects nested ordered lists", () => { expect(hasWideMarkdownBlock("- Parent\n 1. Child\n 2. Child")).toBe(true); expect(hasWideMarkdownBlock("> - Parent\n> 1. Child")).toBe(true); - expect(hasWideMarkdownBlock(" 1. indented code")).toBe(false); - expect(hasWideMarkdownBlock(" - code-like bullet\n 1. indented code")).toBe(false); }); it("can limit ordered-list width pinning to Android", () => { @@ -41,6 +53,14 @@ describe("hasWideMarkdownBlock", () => { ); }); + it("detects blockquotes only when the native renderer needs width pinning", () => { + expect(hasWideMarkdownBlock("> quoted", { includeBlockquotes: true })).toBe(true); + expect(hasWideMarkdownBlock(" > quoted", { includeBlockquotes: true })).toBe(true); + expect(hasWideMarkdownBlock("> quoted")).toBe(false); + expect(hasWideMarkdownBlock("prose > quoted", { includeBlockquotes: true })).toBe(false); + expect(hasWideMarkdownBlock(" > indented code", { includeBlockquotes: true })).toBe(true); + }); + it("detects GFM tables", () => { expect(hasWideMarkdownBlock("| a | b |\n| --- | --- |\n| 1 | 2 |")).toBe(true); expect(hasWideMarkdownBlock("a | b\n:-- | --:\n1 | 2")).toBe(true); diff --git a/apps/mobile/src/lib/wideMarkdownBlocks.ts b/apps/mobile/src/lib/wideMarkdownBlocks.ts index 3c7279fc4..57588bab2 100644 --- a/apps/mobile/src/lib/wideMarkdownBlocks.ts +++ b/apps/mobile/src/lib/wideMarkdownBlocks.ts @@ -1,6 +1,7 @@ /** - * Detects markdown that the JS renderer draws as a block requiring a definite - * user-bubble width — fenced code blocks, GFM tables, and ordered lists. + * Detects markdown that the renderer draws as a block requiring a definite + * user-bubble width: fenced and indented code blocks, GFM tables, ordered + * lists, and blockquotes when requested by the caller. * * Fenced code blocks and tables report an intrinsic width equal to their * widest line, which is effectively unbounded. A user bubble sizes itself @@ -31,6 +32,7 @@ const BLOCKQUOTE_PREFIX = /^ {0,3}>[ \t]?/; export interface WideMarkdownBlockOptions { readonly includeOrderedLists?: boolean; + readonly includeBlockquotes?: boolean; } function stripBlockquotePrefixes(line: string): string { @@ -41,6 +43,32 @@ function stripBlockquotePrefixes(line: string): string { return content; } +function hasIndentedCodeBlock(text: string): boolean { + return text.split("\n").some((rawLine) => { + const line = stripBlockquotePrefixes(rawLine); + let column = 0; + let index = 0; + + // Markdown tabs advance to the next four-column stop. + while (index < line.length) { + if (line[index] === " ") { + column += 1; + } else if (line[index] === "\t") { + column += 4 - (column % 4); + } else { + break; + } + index += 1; + } + + return column >= 4 && index < line.length && line[index] !== "\r"; + }); +} + +function hasBlockquote(text: string): boolean { + return text.split("\n").some((line) => BLOCKQUOTE_PREFIX.test(line)); +} + function hasOrderedListItem(text: string): boolean { let previousNonEmptyLine: string | null = null; @@ -80,6 +108,12 @@ export function hasWideMarkdownBlock( if (FENCED_CODE_BLOCK.test(text)) { return true; } + if (options.includeBlockquotes === true && hasBlockquote(text)) { + return true; + } + if (hasIndentedCodeBlock(text)) { + return true; + } if (options.includeOrderedLists !== false && hasOrderedListItem(text)) { return true; } diff --git a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx index 7c2c037ee..55c2f818b 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx @@ -8,6 +8,8 @@ import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter" type MobileSelectableMarkdownTextProps = Omit; export type { + MarkdownFileContextMenu, + MarkdownFileContextMenuAction, MarkdownImageRenderer, MarkdownImageRequest, NativeMarkdownTextStyle, diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index 7ee4d21b1..8b54df001 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/ type MobileSelectableMarkdownTextProps = Omit; export type { + MarkdownFileContextMenu, + MarkdownFileContextMenuAction, MarkdownImageRenderer, MarkdownImageRequest, NativeMarkdownTextStyle, diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index cf4c29c60..f14a73f15 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -41,10 +41,9 @@ export interface Preferences { readonly legacyThreadListEnabled?: boolean; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; - /** Undefined preserves the default expanded Settled shelf. */ - readonly threadListV2SettledShelfExpanded?: boolean; - /** Undefined preserves the default collapsed Snoozed shelf. */ - readonly threadListV2SnoozedShelfExpanded?: boolean; + /** Fresh keys reset both shelves to collapsed when users update. */ + readonly threadListSettledShelfExpanded?: boolean; + readonly threadListSnoozedShelfExpanded?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -102,8 +101,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { projectGroupingMode?: SidebarProjectGroupingMode; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; - threadListV2SettledShelfExpanded?: boolean; - threadListV2SnoozedShelfExpanded?: boolean; + threadListSettledShelfExpanded?: boolean; + threadListSnoozedShelfExpanded?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -171,11 +170,11 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } - if (typeof parsed.threadListV2SettledShelfExpanded === "boolean") { - preferences.threadListV2SettledShelfExpanded = parsed.threadListV2SettledShelfExpanded; + if (typeof parsed.threadListSettledShelfExpanded === "boolean") { + preferences.threadListSettledShelfExpanded = parsed.threadListSettledShelfExpanded; } - if (typeof parsed.threadListV2SnoozedShelfExpanded === "boolean") { - preferences.threadListV2SnoozedShelfExpanded = parsed.threadListV2SnoozedShelfExpanded; + if (typeof parsed.threadListSnoozedShelfExpanded === "boolean") { + preferences.threadListSnoozedShelfExpanded = parsed.threadListSnoozedShelfExpanded; } return preferences; } diff --git a/apps/mobile/src/state/auth.ts b/apps/mobile/src/state/auth.ts deleted file mode 100644 index 835dee7f7..000000000 --- a/apps/mobile/src/state/auth.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createAuthEnvironmentAtoms } from "@t3tools/client-runtime/state/auth"; - -import { connectionAtomRuntime } from "../connection/runtime"; - -export const authEnvironment = createAuthEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/git.ts b/apps/mobile/src/state/git.ts deleted file mode 100644 index 66bb3dc0b..000000000 --- a/apps/mobile/src/state/git.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createGitEnvironmentAtoms } from "@t3tools/client-runtime/state/git"; - -import { connectionAtomRuntime } from "../connection/runtime"; - -export const gitEnvironment = createGitEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/use-composer-path-search.ts b/apps/mobile/src/state/use-composer-path-search.ts deleted file mode 100644 index 485b472dc..000000000 --- a/apps/mobile/src/state/use-composer-path-search.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type ComposerPathSearchTarget } from "@t3tools/client-runtime/state/threads"; - -import { useComposerPathSearch as useComposerPathSearchQuery } from "../state/queries"; - -export function useComposerPathSearch(target: ComposerPathSearchTarget) { - return useComposerPathSearchQuery(target); -} diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index 0c10d7b3f..8e4eb2796 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -1,15 +1,33 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { - createLinkedPullRequestDetailAtomFamily, + createLinkedPullRequestSummaryAtomFamily, pullRequestDetailToVcsStatus, } from "@t3tools/client-runtime/state/pull-requests"; +import { Atom } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "./atom-registry"; import { useEnvironmentQuery } from "./query"; import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; import { vcsEnvironment } from "./vcs"; -const linkedPullRequestDetailAtom = createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime); +const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); +const MAX_THREAD_PR_SNAPSHOTS = 500; + +interface ThreadPrSnapshot { + readonly identity: string; + readonly presentation: ThreadPrPresentation; +} + +// One bounded cache survives row virtualization without retaining one live +// atom for every thread, branch, directory, or linked pull request ever seen. +const threadPrSnapshotsAtom = Atom.make>(new Map()).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:thread-pr-snapshots"), +); export { presentThreadPr, @@ -28,6 +46,19 @@ export function useThreadPr( projectCwd: string | null, ): ThreadPrPresentation | null { const cwd = thread.worktreePath ?? projectCwd; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const snapshotIdentity = JSON.stringify( + thread.linkedPullRequest ?? { branch: thread.branch, cwd }, + ); + // Select this row's entry so writes for other rows do not re-render it. + const snapshotEntry = useAtomValue( + threadPrSnapshotsAtom, + useCallback( + (current: ReadonlyMap) => current.get(threadKey), + [threadKey], + ), + ); + const snapshot = snapshotEntry?.identity === snapshotIdentity ? snapshotEntry.presentation : null; const gitStatus = useEnvironmentQuery( thread.linkedPullRequest == null && thread.branch !== null && cwd !== null ? vcsEnvironment.status({ @@ -49,23 +80,49 @@ export function useThreadPr( }), ); - if (thread.linkedPullRequest != null) { - const detail = linkedPullRequest.data; - return detail === null - ? null - : presentThreadPr(pullRequestDetailToVcsStatus(detail), { - kind: detail.provider, - name: detail.provider, - baseUrl: "", - }); - } + const live = useMemo(() => { + if (thread.linkedPullRequest != null) { + const detail = linkedPullRequest.data; + return detail === null + ? undefined + : presentThreadPr(pullRequestDetailToVcsStatus(detail), { + kind: detail.provider, + name: detail.provider, + baseUrl: "", + }); + } + + const status = gitStatus.data; + if (thread.branch === null) return null; + if (status === null) return undefined; + if (status.refName !== thread.branch || !status.pr) return null; + return presentThreadPr(status.pr, status.sourceControlProvider); + }, [gitStatus.data, linkedPullRequest.data, thread.branch, thread.linkedPullRequest]); + + useEffect(() => { + if (live === undefined) return; + appAtomRegistry.modify(threadPrSnapshotsAtom, (current) => { + const existing = current.get(threadKey); + if (live === null) { + if (existing === undefined) return [false, current]; + const next = new Map(current); + next.delete(threadKey); + return [true, next]; + } + if (existing?.identity === snapshotIdentity && existing.presentation === live) { + return [false, current]; + } + const next = new Map(current); + next.delete(threadKey); + next.set(threadKey, { identity: snapshotIdentity, presentation: live }); + while (next.size > MAX_THREAD_PR_SNAPSHOTS) { + const oldestKey = next.keys().next().value; + if (oldestKey === undefined) break; + next.delete(oldestKey); + } + return [true, next]; + }); + }, [live, snapshotIdentity, threadKey]); - const status = gitStatus.data; - if (status === null || thread.branch === null || status.refName !== thread.branch) { - return null; - } - if (!status.pr) { - return null; - } - return presentThreadPr(status.pr, status.sourceControlProvider); + return live === undefined ? snapshot : live; } diff --git a/apps/server/integration/NetworkTransferMeasurement.integration.ts b/apps/server/integration/NetworkTransferMeasurement.integration.ts index 75714d151..c18b7d14b 100644 --- a/apps/server/integration/NetworkTransferMeasurement.integration.ts +++ b/apps/server/integration/NetworkTransferMeasurement.integration.ts @@ -5,8 +5,10 @@ import * as NodeZlib from "node:zlib"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import { WsRpcGroup } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; @@ -101,6 +103,8 @@ export interface WebSocketTransferRecorder { ) => globalThis.WebSocket; readonly totals: () => WebSocketTransferTotals; readonly negotiatedExtensions: () => string; + /** Resolves once the upgrade completes, so totals taken after it exclude the upgrade response. */ + readonly awaitOpen: Effect.Effect; } interface NodeWebSocketWithTransport extends NodeSocket.NodeWS.WebSocket { @@ -118,8 +122,15 @@ function rawDataBytes(data: NodeSocket.NodeWS.RawData): number { export function makeWebSocketTransferRecorder(): WebSocketTransferRecorder { let socket: NodeWebSocketWithTransport | null = null; + // Held separately from the WebSocket so wire totals survive a close, which + // is when a reconnect measurement reads them. + let transport: NodeWebSocketWithTransport["_socket"] | null = null; let decodedBytes = 0; let messages = 0; + let resolveOpen: () => void = () => {}; + const opened = new Promise((resolve) => { + resolveOpen = resolve; + }); return { connect: (url, protocols, cookie) => { @@ -128,6 +139,10 @@ export function makeWebSocketTransferRecorder(): WebSocketTransferRecorder { perMessageDeflate: true, }) as NodeWebSocketWithTransport; socket = nextSocket; + nextSocket.once("open", () => { + transport = nextSocket._socket ?? null; + resolveOpen(); + }); nextSocket.on("message", (data) => { const bytes = rawDataBytes(data); decodedBytes += bytes; @@ -136,11 +151,17 @@ export function makeWebSocketTransferRecorder(): WebSocketTransferRecorder { return nextSocket as unknown as globalThis.WebSocket; }, totals: () => ({ - wireBytes: socket?._socket?.bytesRead ?? 0, + wireBytes: transport?.bytesRead ?? socket?._socket?.bytesRead ?? 0, decodedBytes, messages, }), negotiatedExtensions: () => socket?.extensions ?? "", + awaitOpen: Effect.promise(() => opened).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.die(new Error("Timed out waiting for the WebSocket to open")), + }), + ), }; } @@ -175,3 +196,40 @@ export function countingWsRpcProtocolLayer(input: { export const makeCountingWsRpcClient = RpcClient.make(WsRpcGroup); export type CountingWsRpcClient = Effect.Success; + +export interface MeasuredWsClient { + readonly client: CountingWsRpcClient; + readonly recorder: WebSocketTransferRecorder; + /** Fork subscription consumers here so they stop before the socket closes. */ + readonly scope: Scope.Scope; + /** Closes the socket now. The enclosing scope closes it otherwise. */ + readonly close: Effect.Effect; +} + +/** + * Opens one WebSocket RPC client on a child of the current scope. Several + * clients can share one test scope and still disconnect independently, which + * a reconnect measurement needs. + */ +export const openMeasuredWsClient = Effect.fn("TransferBudget.openMeasuredWsClient")( + function* (input: { readonly url: string; readonly cookie: string }) { + const recorder = makeWebSocketTransferRecorder(); + const parent = yield* Effect.scope; + const scope = yield* Scope.fork(parent); + const protocol = yield* Layer.buildWithScope( + countingWsRpcProtocolLayer({ url: input.url, cookie: input.cookie, recorder }), + scope, + ); + const client = yield* makeCountingWsRpcClient.pipe( + Effect.provide(protocol), + Scope.provide(scope), + ); + yield* recorder.awaitOpen; + return { + client, + recorder, + scope, + close: Scope.close(scope, Exit.void), + } satisfies MeasuredWsClient; + }, +); diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c43486623..b0173d5b4 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -22,6 +22,7 @@ import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as Tracer from "effect/Tracer"; import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts"; import { TextGeneration, type TextGenerationShape } from "../src/textGeneration/TextGeneration.ts"; @@ -29,7 +30,7 @@ import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/La import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts"; import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; @@ -45,7 +46,7 @@ import { ProviderEventLoggers, } from "../src/provider/Layers/ProviderEventLoggers.ts"; import { ProviderService } from "../src/provider/Services/ProviderService.ts"; -import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; +import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts"; import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; @@ -231,6 +232,8 @@ export interface OrchestrationIntegrationHarness { interface MakeOrchestrationIntegrationHarnessOptions { readonly provider?: ProviderDriverKind; readonly realCodex?: boolean; + /** Tracer for every fiber the harness runtime runs, including reactors. */ + readonly tracer?: Tracer.Tracer; } export const makeOrchestrationIntegrationHarness = ( @@ -271,7 +274,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(OrchestrationCommandReceiptRepositoryLive), ); const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), + Layer.provide(ProviderSessionRuntime.layer), ); const realCodexRegistry = Layer.effect( ProviderAdapterRegistry, @@ -399,6 +402,9 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge( + options?.tracer ? Layer.succeed(Tracer.Tracer, options.tracer) : Layer.empty, + ), ); const runtime = ManagedRuntime.make(layer); diff --git a/apps/server/integration/SqlStatementCounter.integration.ts b/apps/server/integration/SqlStatementCounter.integration.ts new file mode 100644 index 000000000..e3a3aac3f --- /dev/null +++ b/apps/server/integration/SqlStatementCounter.integration.ts @@ -0,0 +1,24 @@ +import * as Tracer from "effect/Tracer"; + +export interface SqlStatementCounter { + readonly tracer: Tracer.Tracer; + /** Statements executed so far. Read before and after a phase and subtract. */ + readonly count: () => number; +} + +/** + * Counts `sql.execute` spans, which the Effect SQL client opens once per + * statement. Spans behave exactly as with the native tracer. Install the same + * counter on every runtime under test, otherwise statements run by background + * reactors and statements run by request handlers land in different counters. + */ +export function makeSqlStatementCounter(): SqlStatementCounter { + let statements = 0; + const tracer = Tracer.make({ + span: (options) => { + if (options.name === "sql.execute") statements += 1; + return new Tracer.NativeSpan(options); + }, + }); + return { tracer, count: () => statements }; +} diff --git a/apps/server/integration/TransferBudgetReport.integration.ts b/apps/server/integration/TransferBudgetReport.integration.ts index c8ee75e67..e0fe8c13d 100644 --- a/apps/server/integration/TransferBudgetReport.integration.ts +++ b/apps/server/integration/TransferBudgetReport.integration.ts @@ -12,10 +12,28 @@ import { TRANSFER_MEASURED_TOOLS, } from "./fixtures/transferBudget.ts"; +/** Catch-up delivered to a resubscribing client, and which path the server chose. */ +export interface WebSocketCatchUpMeasurement extends WebSocketTransferTotals { + readonly mode: "replay" | "snapshot"; +} + export interface TransferBudgetRun { readonly provider: ProviderDriverKind; readonly threadSnapshot: HttpTransferMeasurement; + /** One socket holding only the thread subscription. This is the capped measurement. */ readonly measuredTurnWebSocket: WebSocketTransferTotals; + readonly shellSnapshot: HttpTransferMeasurement; + /** One socket holding only the shell (sidebar) subscription during the same turn. */ + readonly measuredTurnShellWebSocket: WebSocketTransferTotals; + /** A second client: one socket holding both the thread and shell subscriptions. */ + readonly measuredTurnSecondClientWebSocket: WebSocketTransferTotals; + /** The second client resubscribes after the turn from the cursor it held before it. */ + readonly reconnectThread: WebSocketCatchUpMeasurement; + readonly reconnectShell: WebSocketCatchUpMeasurement; + /** `sql.execute` spans opened server-wide during the measured turn. */ + readonly measuredTurnSqlStatements: number; + /** `sql.execute` spans opened while serving both reconnect catch-ups. */ + readonly reconnectSqlStatements: number; } interface ProviderTransferBudget { @@ -47,6 +65,15 @@ function totalWireBytes(run: TransferBudgetRun): number { return run.threadSnapshot.wireBytes + run.measuredTurnWebSocket.wireBytes; } +/** Bytes the server wrote to every measured socket during the turn. */ +function serverEgressWireBytes(run: TransferBudgetRun): number { + return ( + run.measuredTurnWebSocket.wireBytes + + run.measuredTurnShellWebSocket.wireBytes + + run.measuredTurnSecondClientWebSocket.wireBytes + ); +} + function observedTransfer(run: TransferBudgetRun) { return { totalWireBytes: totalWireBytes(run), @@ -105,6 +132,33 @@ function row( return `| ${provider} | ${phase} | ${metric} | ${format(observed)} | ${format(maximum)} | ${status} |`; } +// Shell, second-client, reconnect, and SQL rows are reported without a cap. +// Shell delivery coalesces on a 50 ms window, so message counts and bytes move +// with scheduler timing between runs, and the reconnect and SQL figures follow +// the same batches. The rows exist so CI shows the numbers next to the capped +// thread measurement. +function infoRow( + provider: ProviderDriverKind, + phase: string, + metric: string, + observed: number, + format: (value: number) => string = formatBytes, +): string { + return `| ${provider} | ${phase} | ${metric} | ${format(observed)} | none | INFO |`; +} + +function webSocketRows( + provider: ProviderDriverKind, + phase: string, + totals: WebSocketTransferTotals, +): string[] { + return [ + infoRow(provider, phase, "WebSocket wire", totals.wireBytes), + infoRow(provider, phase, "WebSocket decoded", totals.decodedBytes), + infoRow(provider, phase, "WebSocket messages", totals.messages, String), + ]; +} + export function transferBudgetViolations(runs: ReadonlyArray): string[] { const violations: string[] = []; for (const run of runs) { @@ -146,6 +200,7 @@ export function formatTransferBudgetReport(runs: ReadonlyArray( + queue: Queue.Queue, + predicate: (value: A) => boolean, + waitDescription: string, +) { + return yield* Effect.gen(function* () { + const values: A[] = []; + while (true) { + const value = yield* Queue.take(queue); + values.push(value); + if (predicate(value)) return values; + } + }).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.die(new Error(`Timed out waiting for ${waitDescription}`)), + }), + ); +}); + +/** + * Resumes the thread subscription from a cursor on a measured client. The + * consumer runs in the client's scope, so closing the client stops it. Callers + * read items from the returned queue. + */ +export const subscribeThreadItems = Effect.fn("TransferBudget.subscribeThreadItems")(function* ( + measured: MeasuredWsClient, + afterSequence: number, +) { + const items = yield* Queue.unbounded(); + yield* measured.client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: TRANSFER_THREAD_ID, + afterSequence, + requestCompletionMarker: true, + }).pipe( + Stream.runForEach((item) => Queue.offer(items, item).pipe(Effect.asVoid)), + Scope.provide(measured.scope), + Effect.forkIn(measured.scope), + ); + return items; +}); + +/** Shell counterpart of subscribeThreadItems. */ +export const subscribeShellItems = Effect.fn("TransferBudget.subscribeShellItems")(function* ( + measured: MeasuredWsClient, + afterSequence: number, +) { + const items = yield* Queue.unbounded(); + yield* measured.client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence, + requestCompletionMarker: true, + }).pipe( + Stream.runForEach((item) => Queue.offer(items, item).pipe(Effect.asVoid)), + Scope.provide(measured.scope), + Effect.forkIn(measured.scope), + ); + return items; +}); + +/** Waits for the initial catch-up and reports whether it was a replay or a snapshot reset. */ +export const awaitSubscriptionSynchronized = Effect.fn( + "TransferBudget.awaitSubscriptionSynchronized", +)(function* ( + items: Queue.Queue, + waitDescription: string, +) { + const initial = yield* collectQueueUntil( + items, + (item) => item.kind === "synchronized", + waitDescription, + ); + return initial.some((item) => item.kind === "snapshot") + ? ("snapshot" as const) + : ("replay" as const); +}); + export { TRANSFER_HISTORY_TURN_COUNT, waitForTurnQuiesced }; diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 55a9ec0ce..af35b310c 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -25,7 +25,7 @@ import { } from "../src/provider/Services/ProviderService.ts"; import * as ServerConfig from "../src/config.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; -import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; +import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; diff --git a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts deleted file mode 100644 index 7e4e88aeb..000000000 --- a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts +++ /dev/null @@ -1,442 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeChildProcess from "node:child_process"; -import * as NodeProcess from "node:process"; -import * as NodeReadline from "node:readline"; -import * as NodeTimers from "node:timers"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import * as Effect from "effect/Effect"; - -type JsonPrimitive = null | boolean | number | string; -type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; - -type JsonRpcId = number | string; - -type JsonRpcMessage = { - jsonrpc?: string; - id?: JsonRpcId; - method?: string; - params?: JsonValue; - result?: JsonValue; - error?: JsonValue; - headers?: JsonValue; -}; - -type SelectLeafOption = { - value: string; - label?: string; - name?: string; -}; - -type SelectGroupOption = { - label?: string; - name?: string; - options: SelectLeafOption[]; -}; - -type SessionConfigOption = { - id: string; - name?: string; - category?: string; - type?: string; - options?: Array; -}; - -type SessionNewResult = { - sessionId: string; - configOptions?: SessionConfigOption[]; -}; - -type SetConfigResult = { - configOptions?: SessionConfigOption[]; -}; - -type PendingRequest = { - method: string; - resolve: (value: JsonValue | undefined) => void; - reject: (error: Error) => void; -}; - -const targetCwd = NodeProcess.argv[2] ?? NodeProcess.cwd(); -const targetModel = NodeProcess.argv[3] ?? "gpt-5.4"; -const promptText = NodeProcess.argv[4] ?? "helo"; -const targetReasoning = NodeProcess.env.CURSOR_REASONING ?? ""; -const targetContext = NodeProcess.env.CURSOR_CONTEXT ?? ""; -const targetFast = NodeProcess.env.CURSOR_FAST ?? ""; -const agentBin = NodeProcess.env.CURSOR_AGENT_BIN ?? "cursor-agent"; -const promptWaitMs = Number(NodeProcess.env.CURSOR_PROMPT_WAIT_MS ?? "4000"); -const requestTimeoutMs = Number(NodeProcess.env.CURSOR_REQUEST_TIMEOUT_MS ?? "20000"); - -function logSection(title: string, value: unknown) { - NodeProcess.stdout.write(`\n=== ${title} ===\n`); - NodeProcess.stdout.write(`${JSON.stringify(value, null, 2)}\n`); -} - -function fail(message: string): never { - throw new Error(message); -} - -function asString(value: JsonValue | undefined): string | null { - return typeof value === "string" ? value : null; -} - -function flattenSelectValues(option: SessionConfigOption | undefined): string[] { - if (!option || option.type !== "select" || !Array.isArray(option.options)) { - return []; - } - - const values: string[] = []; - for (const entry of option.options) { - if (!entry || typeof entry !== "object") { - continue; - } - if ("value" in entry && typeof entry.value === "string") { - values.push(entry.value); - continue; - } - if ("options" in entry && Array.isArray(entry.options)) { - for (const nested of entry.options) { - if (nested && typeof nested === "object" && typeof nested.value === "string") { - values.push(nested.value); - } - } - } - } - return values; -} - -function findConfigOption( - configOptions: SessionConfigOption[], - predicate: (option: SessionConfigOption) => boolean, -): SessionConfigOption | undefined { - return configOptions.find(predicate); -} - -function matchesKeyword(option: SessionConfigOption, keyword: string): boolean { - const haystack = `${option.id} ${option.name ?? ""}`.toLowerCase(); - return haystack.includes(keyword.toLowerCase()); -} - -function sleep(ms: number) { - return new Promise((resolve) => { - // @effect-diagnostics-next-line globalTimers:off - Standalone Node probe script, not an Effect runtime test. - NodeTimers.setTimeout(resolve, ms); - }); -} - -class JsonRpcChild { - readonly child: NodeChildProcess.ChildProcessWithoutNullStreams; - readonly pending = new Map(); - nextId = 1; - closed = false; - - constructor(bin: string, args: string[], cwd: string) { - const spawnCommand = Effect.runSync(resolveSpawnCommand(bin, args)); - this.child = NodeChildProcess.spawn(spawnCommand.command, spawnCommand.args, { - cwd, - shell: spawnCommand.shell, - stdio: ["pipe", "pipe", "pipe"], - env: NodeProcess.env, - }); - - this.child.on("exit", (code, signal) => { - this.closed = true; - const detail = `ACP process exited (code=${String(code)}, signal=${String(signal)})`; - for (const pending of this.pending.values()) { - pending.reject(new Error(`${detail} while waiting for ${pending.method}`)); - } - this.pending.clear(); - }); - - this.child.on("error", (error) => { - this.closed = true; - for (const pending of this.pending.values()) { - pending.reject(error); - } - this.pending.clear(); - }); - - const stdout = NodeReadline.createInterface({ input: this.child.stdout }); - stdout.on("line", (line) => { - void this.handleStdoutLine(line); - }); - - const stderr = NodeReadline.createInterface({ input: this.child.stderr }); - stderr.on("line", (line) => { - NodeProcess.stdout.write(`[stderr] ${line}\n`); - }); - } - - write(message: JsonRpcMessage) { - if (this.closed) { - fail("ACP process is already closed."); - } - const payload = JSON.stringify({ - jsonrpc: "2.0", - headers: [], - ...message, - }); - NodeProcess.stdout.write(`>>> ${payload}\n`); - this.child.stdin.write(`${payload}\n`); - } - - async request(method: string, params: JsonValue, timeoutMs = requestTimeoutMs) { - const id = this.nextId++; - - const responsePromise = new Promise((resolve, reject) => { - // @effect-diagnostics-next-line globalTimers:off - Standalone Node probe script request timeout. - const timeout = NodeTimers.setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Timed out waiting for ${method} response after ${timeoutMs}ms.`)); - }, timeoutMs); - - this.pending.set(id, { - method, - resolve: (value) => { - NodeTimers.clearTimeout(timeout); - resolve(value); - }, - reject: (error) => { - NodeTimers.clearTimeout(timeout); - reject(error); - }, - }); - }); - - this.write({ - id, - method, - params, - }); - - return responsePromise; - } - - notify(method: string, params: JsonValue) { - this.write({ - method, - params, - }); - } - - respond(id: JsonRpcId, result: JsonValue) { - this.write({ - id, - result, - }); - } - - respondError(id: JsonRpcId, code: number, message: string) { - this.write({ - id, - error: { - code, - message, - }, - }); - } - - async handleStdoutLine(line: string) { - if (line.trim().length === 0) { - return; - } - - NodeProcess.stdout.write(`<<< ${line}\n`); - - let message: JsonRpcMessage; - try { - message = JSON.parse(line) as JsonRpcMessage; - } catch (error) { - NodeProcess.stdout.write(`[parse-error] ${(error as Error).message}\n`); - return; - } - - if (typeof message.id !== "undefined" && !message.method) { - const pending = this.pending.get(message.id); - if (!pending) { - return; - } - this.pending.delete(message.id); - if (typeof message.error !== "undefined") { - pending.reject( - new Error(`RPC ${pending.method} failed: ${JSON.stringify(message.error, null, 2)}`), - ); - return; - } - pending.resolve(message.result); - return; - } - - if (message.method === "session/request_permission" && typeof message.id !== "undefined") { - this.respond(message.id, { - outcome: { - outcome: "selected", - optionId: "allow", - }, - }); - return; - } - - if (typeof message.id !== "undefined" && message.id !== "") { - this.respondError( - message.id, - -32601, - `Unhandled server request: ${message.method ?? "unknown"}`, - ); - } - } - - async close() { - if (this.closed) { - return; - } - this.child.kill("SIGTERM"); - await sleep(250); - if (!this.closed) { - this.child.kill("SIGKILL"); - } - } -} - -async function setSelectOptionIfAdvertised( - rpc: JsonRpcChild, - sessionId: string, - configOptions: SessionConfigOption[], - predicate: (option: SessionConfigOption) => boolean, - value: string, - label: string, -) { - if (value.length === 0) { - return configOptions; - } - - const option = findConfigOption(configOptions, predicate); - const values = flattenSelectValues(option); - if (!option || !values.includes(value)) { - logSection(`SKIP_${label}`, { - requestedValue: value, - availableValues: values, - }); - return configOptions; - } - - const response = (await rpc.request("session/set_config_option", { - sessionId, - configId: option.id, - value, - })) as SetConfigResult | null | undefined; - - logSection(`SET_${label}_RESPONSE`, response); - return response?.configOptions ?? configOptions; -} - -async function main() { - const rpc = new JsonRpcChild(agentBin, ["acp"], targetCwd); - - try { - const initializeResponse = await rpc.request("initialize", { - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - _meta: { - parameterizedModelPicker: true, - }, - }, - clientInfo: { - name: "cursor-acp-model-mismatch-probe", - version: "0.0.0", - }, - }); - logSection("INITIALIZE_RESPONSE", initializeResponse); - - const authenticateResponse = await rpc.request("authenticate", { - methodId: "cursor_login", - }); - logSection("AUTHENTICATE_RESPONSE", authenticateResponse); - - const sessionResponse = (await rpc.request("session/new", { - cwd: targetCwd, - mcpServers: [], - })) as SessionNewResult; - logSection("SESSION_NEW_RESPONSE", sessionResponse); - - const sessionId = asString(sessionResponse.sessionId); - if (!sessionId) { - fail("session/new did not return a sessionId."); - } - - let configOptions = sessionResponse.configOptions ?? []; - const modelConfig = findConfigOption(configOptions, (option) => option.category === "model"); - const advertisedModels = flattenSelectValues(modelConfig); - logSection("ADVERTISED_MODEL_VALUES", advertisedModels); - - if (!modelConfig || modelConfig.type !== "select") { - fail("Cursor ACP did not expose a select-type model config option."); - } - - if (!advertisedModels.includes(targetModel)) { - fail( - `Cursor ACP did not advertise model ${JSON.stringify(targetModel)}. Advertised values: ${advertisedModels.join(", ")}`, - ); - } - - const setModelResponse = (await rpc.request("session/set_config_option", { - sessionId, - configId: modelConfig.id, - value: targetModel, - })) as SetConfigResult | null | undefined; - logSection("SET_MODEL_RESPONSE", setModelResponse); - - configOptions = setModelResponse?.configOptions ?? configOptions; - - configOptions = await setSelectOptionIfAdvertised( - rpc, - sessionId, - configOptions, - (option) => option.category === "thought_level", - targetReasoning, - "REASONING", - ); - - configOptions = await setSelectOptionIfAdvertised( - rpc, - sessionId, - configOptions, - (option) => option.category === "model_config" && matchesKeyword(option, "context"), - targetContext, - "CONTEXT", - ); - - configOptions = await setSelectOptionIfAdvertised( - rpc, - sessionId, - configOptions, - (option) => option.category === "model_config" && matchesKeyword(option, "fast"), - targetFast, - "FAST", - ); - - const promptResponse = await rpc.request("session/prompt", { - sessionId, - prompt: [ - { - type: "text", - text: promptText, - }, - ], - }); - logSection("PROMPT_RESPONSE", promptResponse); - - await sleep(promptWaitMs); - rpc.notify("session/cancel", { sessionId }); - } finally { - await rpc.close(); - } -} - -void main().catch((error: unknown) => { - NodeProcess.stderr.write( - `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, - ); - process.exitCode = 1; -}); diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index 46305e3b3..3974818b6 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -6,7 +6,7 @@ import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../src/persistence/Migrations.ts"; -import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runMigrateDevDb } from "./migrate-dev-db.ts"; const withDatabase = ( diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 65974e37f..988dc142e 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -39,7 +39,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { Command, Flag } from "effect/unstable/cli"; import { migrationManifest, runMigrations } from "../src/persistence/Migrations.ts"; -import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; export class MigrateDevDbNotInWorktreeError extends Schema.TaggedErrorClass()( "MigrateDevDbNotInWorktreeError", diff --git a/apps/server/scripts/t3-sqlite-state.test.ts b/apps/server/scripts/t3-sqlite-state.test.ts index 2e4376a71..08b61d5ac 100644 --- a/apps/server/scripts/t3-sqlite-state.test.ts +++ b/apps/server/scripts/t3-sqlite-state.test.ts @@ -5,7 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runSqliteState } from "./t3-sqlite-state.ts"; const createFixtureDatabase = Effect.fn("createSqliteStateFixtureDatabase")(function* ( diff --git a/apps/server/scripts/t3-sqlite-state.ts b/apps/server/scripts/t3-sqlite-state.ts index dcbb2435c..b08c1a5e2 100644 --- a/apps/server/scripts/t3-sqlite-state.ts +++ b/apps/server/scripts/t3-sqlite-state.ts @@ -15,7 +15,7 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { Argument, Command, Flag } from "effect/unstable/cli"; -import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; export const SqliteStateOperation = Schema.Literals(["query", "exec"]); export type SqliteStateOperation = typeof SqliteStateOperation.Type; diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 4770a7a56..5fdf4d264 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -79,6 +79,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 76ff59e2a..307a87a26 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -162,11 +162,19 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef it.layer(NodeServices.layer)("bin cli parsing", (it) => { it.effect("accepts the built-in lowercase log-level flag values", () => - runCliWithRuntime(["--log-level", "debug", "--version"]), + Effect.gen(function* () { + const { output } = yield* captureStdout(runCli(["--log-level", "debug", "--version"])); + + assert.include(output, "0.0.0"); + }), ); it.effect("accepts canonical --no- boolean negation", () => - runCliWithRuntime(["--no-log-websocket-events", "--version"]), + Effect.gen(function* () { + const { output } = yield* captureStdout(runCli(["--no-log-websocket-events", "--version"])); + + assert.include(output, "0.0.0"); + }), ); it.effect("rejects invalid log-level casing before launching the server", () => diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 951b77e3a..02caf6d9a 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -8,6 +8,7 @@ import * as CliError from "effect/unstable/cli/CliError"; import * as NetService from "@t3tools/shared/Net"; import packageJson from "../package.json" with { type: "json" }; import { authCommand } from "./cli/auth.ts"; +import { appCommand } from "./cli/app.ts"; import { connectCommand } from "./cli/connect.ts"; import { pairCommand } from "./cli/pair.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; @@ -53,6 +54,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => Command.withSubcommands([ startCommand, serveCommand, + appCommand, pairCommand, authCommand, projectCommand, diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts new file mode 100644 index 000000000..23d21cfdd --- /dev/null +++ b/apps/server/src/cli/app.test.ts @@ -0,0 +1,308 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The integration fixture binds the same platform socket or named pipe as the CLI. +import * as NodeFSP from "node:fs/promises"; +import { RUNTIME_HOME_DIR_NAME } from "../os-jank.ts"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import type { DesktopAppActivationRequest } from "@t3tools/contracts"; +import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import { + HostProcessPlatform, + HostProcessUserId, + HostProcessWorkingDirectory, +} from "@t3tools/shared/hostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { Command } from "effect/unstable/cli"; +import { afterEach, describe, expect, vi } from "vite-plus/test"; + +import { makeCli } from "../bin.ts"; + +vi.mock("node:os", async (importOriginal) => { + const os = await importOriginal(); + return { ...os, homedir: vi.fn(os.homedir) }; +}); + +afterEach(() => vi.mocked(NodeOS.homedir).mockReset()); + +const runCli = (args: ReadonlyArray, env: Record = {}) => + Command.runWith(makeCli(), { version: "0.0.0" })(args).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NetService.layer, + ConfigProvider.layer(ConfigProvider.fromEnv({ env })), + ), + ), + ); + +const pathExists = (path: string) => + Effect.promise(() => + NodeFSP.stat(path).then( + () => true, + () => false, + ), + ); + +async function startFakeDesktop(input: { + readonly baseDir: string; + readonly stateSubdirectory?: "userdata" | "dev"; + readonly platform: NodeJS.Platform; + readonly userId: number | undefined; + readonly reply?: (request: DesktopAppActivationRequest) => unknown; +}) { + const target = resolveDesktopAppControlAddress({ + stateDir: NodePath.join(input.baseDir, input.stateSubdirectory ?? "userdata"), + platform: input.platform, + tempDir: NodeOS.tmpdir(), + userId: input.userId, + joinPath: NodePath.join, + }); + if (target.directory !== null) { + await NodeFSP.mkdir(target.directory, { recursive: true, mode: 0o700 }); + await NodeFSP.unlink(target.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } + + const received: DesktopAppActivationRequest[] = []; + const server = NodeNet.createServer((socket) => { + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk; + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + const request = JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationRequest; + received.push(request); + const response = input.reply + ? input.reply(request) + : { + version: 1, + requestId: request.requestId, + ok: true, + projectId: "project-1", + threadId: `thread-${received.length}`, + }; + socket.end(`${JSON.stringify(response)}\n`); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(target.address, resolve); + }); + + return { + received, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + if (target.directory !== null) { + await NodeFSP.unlink(target.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } + }, + }; +} + +const fakeDesktop = Effect.fn(function* ( + input: Omit[0], "platform" | "userId">, +) { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + return yield* Effect.acquireRelease( + Effect.promise(() => startFakeDesktop({ ...input, platform, userId })), + (server) => Effect.promise(() => server.close()), + ); +}); + +const withTempDirectory = ( + prefix: string, + use: (root: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), prefix))), + use, + (root) => Effect.promise(() => NodeFSP.rm(root, { recursive: true, force: true })), + ); + +describe("t3 app", () => { + it.effect("rejects SSH before it tries to reach a desktop app", () => + withTempDirectory("t3-app-ssh-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "missing-t3-home"); + const error = yield* runCli(["app", "--base-dir", baseDir], { + SSH_CONNECTION: "client server", + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "DesktopAppSshUnsupportedError", + message: + "`t3 app` only controls a desktop app on the same machine. It cannot run over SSH.", + }); + expect(yield* pathExists(baseDir)).toBe(false); + }), + ), + ); + + it.effect("rejects unsupported platforms without creating state", () => + withTempDirectory("t3-app-platform-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "missing-t3-home"); + const error = yield* runCli(["app", "--base-dir", baseDir]).pipe( + Effect.provideService(HostProcessPlatform, "freebsd"), + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "DesktopAppPlatformUnsupportedError", + platform: "freebsd", + message: "`t3 app` is not supported on freebsd.", + }); + expect(yield* pathExists(baseDir)).toBe(false); + }), + ), + ); + + it.effect("does not create state when only a server or no desktop app is running", () => + withTempDirectory("t3-app-missing-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "missing-t3-home"); + const error = yield* runCli(["app", "--base-dir", baseDir]).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "DesktopAppUnreachableError", + candidateAddresses: [expect.any(String)], + workspaceRoot: yield* HostProcessWorkingDirectory, + message: expect.stringContaining("Could not reach the T3 Code desktop app."), + cause: { code: "ENOENT" }, + }); + expect(yield* pathExists(baseDir)).toBe(false); + }), + ), + ); + + it.effect("uses T3CODE_HOME or --base-dir and sends the default or explicit path", () => + withTempDirectory("t3-app-command-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "t3-home"); + const explicitPath = NodePath.join(root, "project"); + const platform = yield* HostProcessPlatform; + const workingDirectory = yield* HostProcessWorkingDirectory; + const desktop = yield* fakeDesktop({ baseDir }); + + yield* runCli(["app"], { T3CODE_HOME: baseDir }); + yield* runCli(["app", explicitPath, "--base-dir", baseDir]); + + expect(desktop.received.map((request) => request.workspaceRoot)).toEqual([ + workingDirectory, + explicitPath, + ]); + expect(desktop.received.every((request) => request.platform === platform)).toBe(true); + }).pipe(Effect.scoped), + ), + ); + + it.effect("prefers the installed desktop app when a dev desktop is also running", () => + withTempDirectory("t3-app-preferred-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, RUNTIME_HOME_DIR_NAME); + const desktop = yield* fakeDesktop({ baseDir }); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + yield* runCli(["app"]); + + expect(desktop.received).toHaveLength(1); + expect(development.received).toHaveLength(0); + }).pipe(Effect.scoped), + ), + ); + + it.effect("finds the dev desktop when the default desktop socket is absent", () => + withTempDirectory("t3-app-dev-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, RUNTIME_HOME_DIR_NAME); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + yield* runCli(["app"]); + yield* runCli(["app"], { T3CODE_HOME: " " }); + + expect(development.received).toHaveLength(2); + expect(yield* pathExists(baseDir)).toBe(false); + }).pipe(Effect.scoped), + ), + ); + + it.effect("never searches a dev state directory for an explicit T3 home", () => + withTempDirectory("t3-app-explicit-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, RUNTIME_HOME_DIR_NAME); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + const flagError = yield* runCli(["app", "--base-dir", baseDir]).pipe(Effect.flip); + const envError = yield* runCli(["app"], { T3CODE_HOME: baseDir }).pipe(Effect.flip); + + expect(flagError).toMatchObject({ _tag: "DesktopAppUnreachableError" }); + expect(envError).toMatchObject({ _tag: "DesktopAppUnreachableError" }); + expect(development.received).toHaveLength(0); + }).pipe(Effect.scoped), + ), + ); + + for (const responseKind of ["failure", "invalid"] as const) { + it.effect(`never falls back after the default desktop sends a ${responseKind} response`, () => + withTempDirectory("t3-app-response-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, RUNTIME_HOME_DIR_NAME); + const desktop = yield* fakeDesktop({ + baseDir, + reply: (request) => + responseKind === "failure" + ? { + version: 1, + requestId: request.requestId, + ok: false, + code: "project-create-failed", + message: "The project path is not available.", + } + : { invalid: true }, + }); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + const error = yield* runCli(["app"]).pipe(Effect.flip); + + expect(desktop.received).toHaveLength(1); + expect(development.received).toHaveLength(0); + if (responseKind === "failure") { + expect(error).toMatchObject({ + _tag: "DesktopAppRequestFailedError", + code: "project-create-failed", + requestId: desktop.received[0]?.requestId, + workspaceRoot: yield* HostProcessWorkingDirectory, + message: expect.stringContaining("project-create-failed"), + cause: { + ok: false, + code: "project-create-failed", + message: "The project path is not available.", + }, + }); + } else { + expect(error).toMatchObject({ + _tag: "DesktopAppUnreachableError", + cause: { message: "The desktop app response is invalid." }, + }); + } + }).pipe(Effect.scoped), + ), + ); + } +}); diff --git a/apps/server/src/cli/app.ts b/apps/server/src/cli/app.ts new file mode 100644 index 000000000..85fbebd74 --- /dev/null +++ b/apps/server/src/cli/app.ts @@ -0,0 +1,261 @@ +// @effect-diagnostics globalTimers:off -- The Node socket client owns its response deadline and clears it on every completion path. +import * as NodeCrypto from "node:crypto"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; + +import { + DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + DesktopAppActivationErrorCode, + DesktopAppActivationResponse, + type DesktopAppActivationPlatform, + type DesktopAppActivationRequest, +} from "@t3tools/contracts"; +import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import { + HostProcessPlatform, + HostProcessUserId, + HostProcessWorkingDirectory, +} from "@t3tools/shared/hostProcess"; +import * as Config from "effect/Config"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Argument, Command } from "effect/unstable/cli"; + +import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; +import { baseDirFlag } from "./config.ts"; + +const CLI_RESPONSE_TIMEOUT_MS = 17_000; +const MAX_RESPONSE_BYTES = 64 * 1024; +const isDesktopAppActivationResponse = Schema.is(DesktopAppActivationResponse); + +export class DesktopAppSshUnsupportedError extends Schema.TaggedErrorClass()( + "DesktopAppSshUnsupportedError", + {}, +) { + override get message(): string { + return "`t3 app` only controls a desktop app on the same machine. It cannot run over SSH."; + } +} + +export class DesktopAppPlatformUnsupportedError extends Schema.TaggedErrorClass()( + "DesktopAppPlatformUnsupportedError", + { platform: Schema.String }, +) { + override get message(): string { + return `\`t3 app\` is not supported on ${this.platform}.`; + } +} + +export class DesktopAppUnreachableError extends Schema.TaggedErrorClass()( + "DesktopAppUnreachableError", + { + candidateAddresses: Schema.Array(Schema.String), + requestId: Schema.String, + workspaceRoot: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not reach the T3 Code desktop app. Start or update the desktop app on this machine, then run `t3 app` again. A running T3 Code server is not enough."; + } +} + +export class DesktopAppRequestFailedError extends Schema.TaggedErrorClass()( + "DesktopAppRequestFailedError", + { + code: DesktopAppActivationErrorCode, + requestId: Schema.String, + workspaceRoot: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `T3 Code could not open ${this.workspaceRoot} (${this.code}).`; + } +} + +function isDesktopPlatform(platform: NodeJS.Platform): platform is DesktopAppActivationPlatform { + return platform === "darwin" || platform === "linux" || platform === "win32"; +} + +export function sendDesktopAppActivationRequest(input: { + readonly address: string; + readonly fallbackAddress?: string; + readonly request: DesktopAppActivationRequest; + readonly timeoutMs?: number; +}): Promise { + return new Promise((resolve, reject) => { + const socket = NodeNet.createConnection(input.address); + socket.setEncoding("utf8"); + let buffer = ""; + let settled = false; + let connected = false; + + const finish = ( + result: + | { readonly type: "success"; readonly response: DesktopAppActivationResponse } + | { readonly type: "failure"; readonly error: Error }, + ) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + socket.destroy(); + if (result.type === "success") resolve(result.response); + else reject(result.error); + }; + + const timeout = setTimeout(() => { + finish({ + type: "failure", + error: new Error("The desktop app did not respond in time."), + }); + }, input.timeoutMs ?? CLI_RESPONSE_TIMEOUT_MS); + + socket.once("connect", () => { + connected = true; + socket.write(`${JSON.stringify(input.request)}\n`); + }); + socket.on("data", (chunk) => { + buffer += chunk; + if (Buffer.byteLength(buffer, "utf8") > MAX_RESPONSE_BYTES) { + finish({ type: "failure", error: new Error("The desktop app response is too large.") }); + return; + } + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + + let parsed: unknown; + try { + parsed = JSON.parse(buffer.slice(0, newline)); + } catch { + finish({ + type: "failure", + error: new Error("The desktop app response is not valid JSON."), + }); + return; + } + if (!isDesktopAppActivationResponse(parsed)) { + finish({ type: "failure", error: new Error("The desktop app response is invalid.") }); + return; + } + if (parsed.requestId !== input.request.requestId) { + finish({ + type: "failure", + error: new Error("The desktop app response did not match this request."), + }); + return; + } + finish({ type: "success", response: parsed }); + }); + socket.once("error", (error: NodeJS.ErrnoException) => { + if ( + !settled && + !connected && + input.fallbackAddress !== undefined && + (error.code === "ENOENT" || error.code === "ECONNREFUSED") + ) { + settled = true; + clearTimeout(timeout); + socket.destroy(); + resolve( + sendDesktopAppActivationRequest({ + address: input.fallbackAddress, + request: input.request, + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + }), + ); + return; + } + finish({ type: "failure", error }); + }); + socket.once("end", () => { + finish({ type: "failure", error: new Error("The desktop app closed the connection.") }); + }); + }); +} + +const appEnvironment = Config.all({ + t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), + sshConnection: Config.string("SSH_CONNECTION").pipe(Config.option), + sshTty: Config.string("SSH_TTY").pipe(Config.option), +}); + +const runAppCommand = Effect.fn("cli.app")(function* (flags: { + readonly baseDir: Option.Option; + readonly workspaceRoot: Option.Option; +}) { + const environment = yield* appEnvironment; + const hostPlatform = yield* HostProcessPlatform; + if (Option.isSome(environment.sshConnection) || Option.isSome(environment.sshTty)) { + return yield* new DesktopAppSshUnsupportedError({}); + } + if (!isDesktopPlatform(hostPlatform)) { + return yield* new DesktopAppPlatformUnsupportedError({ platform: hostPlatform }); + } + + const path = yield* Path.Path; + const configuredBaseDir = Option.getOrUndefined(flags.baseDir) ?? environment.t3Home; + const baseDir = yield* resolveBaseDir(configuredBaseDir); + const allowDevFallback = Option.isNone(flags.baseDir) && !environment.t3Home?.trim(); + const rawWorkspaceRoot = + Option.getOrUndefined(flags.workspaceRoot) ?? (yield* HostProcessWorkingDirectory); + const workspaceRoot = path.resolve(yield* expandHomePath(rawWorkspaceRoot)); + const userId = yield* HostProcessUserId; + const resolveAddress = (stateSubdirectory: "userdata" | "dev") => + resolveDesktopAppControlAddress({ + stateDir: path.join(baseDir, stateSubdirectory), + platform: hostPlatform, + tempDir: NodeOS.tmpdir(), + userId, + joinPath: path.join, + }).address; + const request: DesktopAppActivationRequest = { + version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + requestId: NodeCrypto.randomUUID(), + type: "open-workspace", + workspaceRoot, + platform: hostPlatform, + }; + const address = resolveAddress("userdata"); + const fallbackAddress = allowDevFallback ? resolveAddress("dev") : undefined; + + const response = yield* Effect.tryPromise({ + try: () => + sendDesktopAppActivationRequest({ + address, + ...(fallbackAddress === undefined ? {} : { fallbackAddress }), + request, + }), + catch: (cause) => + new DesktopAppUnreachableError({ + candidateAddresses: fallbackAddress === undefined ? [address] : [address, fallbackAddress], + requestId: request.requestId, + workspaceRoot, + cause, + }), + }); + if (!response.ok) { + return yield* new DesktopAppRequestFailedError({ + code: response.code, + requestId: response.requestId, + workspaceRoot, + cause: response, + }); + } + + yield* Console.log(`Opened ${workspaceRoot} in T3 Code.`); +}); + +export const appCommand = Command.make("app", { + baseDir: baseDirFlag, + workspaceRoot: Argument.string("path").pipe( + Argument.withDescription("Project directory. Default: current directory."), + Argument.optional, + ), +}).pipe( + Command.withDescription("Open a project in the running T3 Code desktop app."), + Command.withHandler(runAppCommand), +); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 87babd4fa..9dda700cd 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -21,12 +21,12 @@ export const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).p Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), Flag.optional, ); -export const portFlag = Flag.integer("port").pipe( +const portFlag = Flag.integer("port").pipe( Flag.withSchema(PortSchema), Flag.withDescription("Port for the HTTP/WebSocket server."), Flag.optional, ); -export const hostFlag = Flag.string("host").pipe( +const hostFlag = Flag.string("host").pipe( Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), Flag.optional, ); @@ -36,34 +36,34 @@ export const baseDirFlag = Flag.string("base-dir").pipe( ), Flag.optional, ); -export const devUrlFlag = Flag.string("dev-url").pipe( +const devUrlFlag = Flag.string("dev-url").pipe( Flag.withSchema(Schema.URLFromString), Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), Flag.optional, ); -export const noBrowserFlag = Flag.boolean("no-browser").pipe( +const noBrowserFlag = Flag.boolean("no-browser").pipe( Flag.withDescription("Disable automatic browser opening."), Flag.optional, ); -export const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( +const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( Flag.withSchema(Schema.Int), Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), Flag.optional, ); -export const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( +const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( Flag.withDescription( "Create a project for the current working directory on startup when missing.", ), Flag.optional, ); -export const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( +const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( Flag.withDescription( "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).", ), Flag.withAlias("log-ws-events"), Flag.optional, ); -export const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe( +const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe( Flag.withDescription( "Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet.", ), @@ -161,7 +161,7 @@ export interface CliAuthLocationFlags { readonly devUrl?: Option.Option; } -export const sharedServerLocationFlags = { +export const authLocationFlags = { baseDir: baseDirFlag, devUrl: devUrlFlag, } as const; @@ -190,8 +190,6 @@ export const sharedServerCommandFlags = { tailscaleServePort: tailscaleServePortFlag, } as const; -export const authLocationFlags = sharedServerLocationFlags; - const resolveOptionPrecedence = ( ...values: ReadonlyArray> ): Option.Option => Option.firstSomeOf(values); diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 55352abb8..4505445bb 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -43,6 +43,7 @@ import * as ServerConfig from "../config.ts"; import { resolveBaseDir } from "../os-jank.ts"; import { type PersistedServerRuntimeState, + isProcessAlive, readPersistedServerRuntimeState, } from "../serverRuntimeState.ts"; import { @@ -229,17 +230,6 @@ const probeEnvironmentDescriptor = ( return { _tag: "descriptor", descriptor } as const; }).pipe(Effect.catch((outcome) => Effect.succeed(outcome))); -// signal 0 delivers nothing; it only reports whether the pid exists. EPERM -// means it exists but belongs to another user, which still counts as alive. -const isProcessAlive = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return error instanceof Error && "code" in error && error.code === "EPERM"; - } -}; - interface DiscoveredPairTarget { readonly baseDir: string; readonly variant: PairStateVariant; diff --git a/apps/server/src/cli/triage.ts b/apps/server/src/cli/triage.ts index e60bb4399..0b42edc6b 100644 --- a/apps/server/src/cli/triage.ts +++ b/apps/server/src/cli/triage.ts @@ -29,7 +29,7 @@ import { Command, Flag } from "effect/unstable/cli"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import { resolveBaseDir, warnAboutLegacyRuntimeHome } from "../os-jank.ts"; -import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { baseDirFlag } from "./config.ts"; import { resolveCliCommand } from "./invocation.ts"; import { @@ -76,9 +76,6 @@ export class TriageAgentSpawnError extends Schema.TaggedErrorClass }), ); -const isProcessAlive = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return error instanceof Error && "code" in error && error.code === "EPERM"; - } -}; - /** One human-readable line about the local server, for `context.md`. */ const describeServerProcess = Effect.fn("triage.describeServerProcess")(function* ( serverRuntimeStatePath: string, diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index d8dc37c6c..e19274002 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -30,19 +30,19 @@ import { } from "./serviceProtocol.ts"; const BOOT_SERVICE_NAME = "t3code"; -export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; +const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; // `.service` suffix keeps the label distinct from the desktop app's bundle id // (com.t3tools.t3code), so launchd and TCC records never collide. -export const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; -export const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; -export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; +const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; +const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; +const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; /** systemd expands `%` specifiers, including in unquoted append-log paths. */ -export function escapeSystemdSpecifiers(value: string): string { +function escapeSystemdSpecifiers(value: string): string { return value.replaceAll("%", "%%"); } -export function quoteSystemdValue(value: string): string { +function quoteSystemdValue(value: string): string { const escaped = escapeSystemdSpecifiers(value); return /[\s"'\\]/.test(escaped) ? `"${escaped.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` @@ -92,7 +92,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { } /** Plist values are emitted as XML text nodes; only these three need escaping. */ -export function escapeXmlText(value: string): string { +function escapeXmlText(value: string): string { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } @@ -199,7 +199,7 @@ export interface BootServiceManager { readonly finalize: ReadonlyArray; } -export function systemdManager(input: { +function systemdManager(input: { readonly path: Path.Path; readonly homeDir: string; }): BootServiceManager { @@ -266,7 +266,7 @@ export function systemdManager(input: { }; } -export function launchdManager(input: { +function launchdManager(input: { readonly path: Path.Path; readonly homeDir: string; readonly uid: number; @@ -348,7 +348,7 @@ export function launchdManager(input: { } /** Undefined means this host cannot run the background service. */ -export function selectBootServiceManager(input: { +function selectBootServiceManager(input: { readonly platform: NodeJS.Platform; readonly homeDir: string; readonly uid: number | undefined; diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 0bab85777..079d9b4a8 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -85,6 +85,11 @@ export class GitWorkflowService extends Context.Service< readonly cwd: string; readonly remoteName: string; }) => Effect.Effect; + readonly remoteBranchExists: (input: { + readonly cwd: string; + readonly remoteName: string; + readonly refName: string; + }) => Effect.Effect; readonly resolveRemoteTrackingCommit: (input: { readonly cwd: string; readonly refName: string; @@ -368,6 +373,10 @@ export const make = Effect.gen(function* () { ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe( Effect.andThen(git.remoteExists(input)), ), + remoteBranchExists: (input) => + ensureGitCommand("GitWorkflowService.remoteBranchExists", input.cwd).pipe( + Effect.andThen(git.remoteBranchExists(input)), + ), resolveRemoteTrackingCommit: (input) => ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe( Effect.andThen(git.resolveRemoteTrackingCommit(input)), diff --git a/apps/server/src/httpCors.ts b/apps/server/src/httpCors.ts index aeb8dbce5..3fdc165ba 100644 --- a/apps/server/src/httpCors.ts +++ b/apps/server/src/httpCors.ts @@ -6,9 +6,3 @@ export const browserApiCorsAllowedHeaders = [ "content-type", "dpop", ] as const; - -export const browserApiCorsHeaders = { - "access-control-allow-origin": "*", - "access-control-allow-methods": browserApiCorsAllowedMethods.join(", "), - "access-control-allow-headers": browserApiCorsAllowedHeaders.join(", "), -} as const; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 10d98bf64..1795808bc 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -96,10 +96,6 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( ), ); -export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFromConfig).check( - Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), -); - function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): boolean { return ( left.command === right.command && diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 3baf56a79..33528d8bb 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -33,12 +33,15 @@ const PreviewActionResult = Schema.Record(Schema.String, Schema.Never).annotate( description: "The preview action completed successfully.", }); +/** Drives the real browser and can destroy page state. */ const browserTool = (tool: T): T => tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; +/** Same open-world browser access, but the action does not destroy page state. */ const safeBrowserTool = (tool: T): T => - browserTool(tool).annotate(Tool.Destructive, false) as T; + tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, false) as T; +/** A safe browser action that only observes, so it is also repeatable. */ const readonlyBrowserTool = (tool: T): T => safeBrowserTool(tool).annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true) as T; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index f9208d433..b374849f5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2609,44 +2609,35 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { json_object('requestId', 'user-input-active'), NULL, '2026-02-26T12:35:07.000Z' - ), - ( - 'activity-user-input-active-failed', - 'thread-stale-user-input', - NULL, - 'error', - 'provider.user-input.respond.failed', - 'Provider user input response failed', - json_object( - 'requestId', - 'user-input-active', - 'detail', - 'Provider is temporarily unavailable' - ), - NULL, - '2026-02-26T12:35:08.000Z' ) `; + // A user-input lifecycle activity is one of the events that still + // refreshes the shell summary, so it forces the read under test. yield* appendAndProject({ - type: "thread.message-sent", + type: "thread.activity-appended", eventId: EventId.make("evt-stale-user-input-3"), aggregateKind: "thread", aggregateId: ThreadId.make("thread-stale-user-input"), - occurredAt: "2026-02-26T12:35:09.000Z", + occurredAt: "2026-02-26T12:35:08.000Z", commandId: CommandId.make("cmd-stale-user-input-3"), causationEventId: null, correlationId: CorrelationId.make("cmd-stale-user-input-3"), metadata: {}, payload: { threadId: ThreadId.make("thread-stale-user-input"), - messageId: MessageId.make("message-stale-user-input"), - role: "user", - text: "Continue", - turnId: null, - streaming: false, - createdAt: "2026-02-26T12:35:09.000Z", - updatedAt: "2026-02-26T12:35:09.000Z", + activity: { + id: EventId.make("activity-user-input-active-failed"), + tone: "error", + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + payload: { + requestId: "user-input-active", + detail: "Provider is temporarily unavailable", + }, + turnId: null, + createdAt: "2026-02-26T12:35:08.000Z", + }, }, }); @@ -2661,6 +2652,217 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); + it.effect("maintains shell summary fields across message and activity streams", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-shell-summary-1"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-shell-summary"), + occurredAt: "2026-03-01T08:00:00.000Z", + commandId: CommandId.make("cmd-shell-summary-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-shell-summary-1"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-shell-summary"), + title: "Project Shell Summary", + workspaceRoot: "/tmp/project-shell-summary", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-03-01T08:00:00.000Z", + updatedAt: "2026-03-01T08:00:00.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-shell-summary-2"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-shell-summary"), + occurredAt: "2026-03-01T08:00:01.000Z", + commandId: CommandId.make("cmd-shell-summary-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-shell-summary-2"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-shell-summary"), + projectId: ProjectId.make("project-shell-summary"), + title: "Thread Shell Summary", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-03-01T08:00:01.000Z", + updatedAt: "2026-03-01T08:00:01.000Z", + }, + }); + + const readSummary = sql<{ + readonly latestUserMessageAt: string | null; + readonly pendingUserInputCount: number; + readonly updatedAt: string; + }>` + SELECT + latest_user_message_at AS "latestUserMessageAt", + pending_user_input_count AS "pendingUserInputCount", + updated_at AS "updatedAt" + FROM projection_threads + WHERE thread_id = 'thread-shell-summary' + `; + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make("evt-shell-summary-3"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-shell-summary"), + occurredAt: "2026-03-01T08:00:02.000Z", + commandId: CommandId.make("cmd-shell-summary-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-shell-summary-3"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-shell-summary"), + messageId: MessageId.make("message-shell-summary-user"), + role: "user", + text: "please do the thing", + turnId: TurnId.make("turn-shell-summary-1"), + streaming: false, + createdAt: "2026-03-01T08:00:02.000Z", + updatedAt: "2026-03-01T08:00:02.000Z", + }, + }); + + assert.deepEqual(yield* readSummary, [ + { + latestUserMessageAt: "2026-03-01T08:00:02.000Z", + pendingUserInputCount: 0, + updatedAt: "2026-03-01T08:00:02.000Z", + }, + ]); + + // Streaming assistant deltas bump updatedAt but must not disturb + // latestUserMessageAt or the pending counters. + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make("evt-shell-summary-4"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-shell-summary"), + occurredAt: "2026-03-01T08:00:03.000Z", + commandId: CommandId.make("cmd-shell-summary-4"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-shell-summary-4"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-shell-summary"), + messageId: MessageId.make("message-shell-summary-assistant"), + role: "assistant", + text: "working on it", + turnId: TurnId.make("turn-shell-summary-1"), + streaming: true, + createdAt: "2026-03-01T08:00:03.000Z", + updatedAt: "2026-03-01T08:00:03.000Z", + }, + }); + + assert.deepEqual(yield* readSummary, [ + { + latestUserMessageAt: "2026-03-01T08:00:02.000Z", + pendingUserInputCount: 0, + updatedAt: "2026-03-01T08:00:03.000Z", + }, + ]); + + // Ordinary tool activities bump updatedAt without touching the + // user-input counter; user-input lifecycle activities update it. + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-shell-summary-5"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-shell-summary"), + occurredAt: "2026-03-01T08:00:04.000Z", + commandId: CommandId.make("cmd-shell-summary-5"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-shell-summary-5"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-shell-summary"), + activity: { + id: EventId.make("activity-shell-summary-command"), + tone: "tool", + kind: "command", + summary: "Ran a command", + payload: {}, + turnId: TurnId.make("turn-shell-summary-1"), + createdAt: "2026-03-01T08:00:04.000Z", + }, + }, + }); + + assert.deepEqual(yield* readSummary, [ + { + latestUserMessageAt: "2026-03-01T08:00:02.000Z", + pendingUserInputCount: 0, + updatedAt: "2026-03-01T08:00:04.000Z", + }, + ]); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-shell-summary-6"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-shell-summary"), + occurredAt: "2026-03-01T08:00:05.000Z", + commandId: CommandId.make("cmd-shell-summary-6"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-shell-summary-6"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-shell-summary"), + activity: { + id: EventId.make("activity-shell-summary-user-input"), + tone: "info", + kind: "user-input.requested", + summary: "User input requested", + payload: { + requestId: "user-input-request-shell-summary-1", + questions: [ + { + id: "confirm", + header: "Confirm", + question: "Proceed?", + options: [{ label: "yes", description: "Proceed" }], + }, + ], + }, + turnId: TurnId.make("turn-shell-summary-1"), + createdAt: "2026-03-01T08:00:05.000Z", + }, + }, + }); + + assert.deepEqual(yield* readSummary, [ + { + latestUserMessageAt: "2026-03-01T08:00:02.000Z", + pendingUserInputCount: 1, + updatedAt: "2026-03-01T08:00:05.000Z", + }, + ]); + }), + ); + it.effect("ignores non-stale provider approval response failures", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index aa88be424..57109a914 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -130,12 +130,8 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean { ); } -// A refresh reads each persisted summary source, so skip events that cannot change the result. +// A refresh reads each persisted summary source, so skip activities that cannot change the result. function shouldRefreshThreadShellSummary(event: OrchestrationEvent): boolean { - if (event.type === "thread.message-sent") { - return event.payload.role === "user"; - } - if (event.type !== "thread.activity-appended") { return true; } @@ -909,7 +905,29 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } - case "thread.message-sent": + // A message cannot change any summary field except latestUserMessageAt, + // which is a monotonic maximum that folds in directly. The full refresh + // would re-read every message body in the thread per user message. + case "thread.message-sent": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + const previousLatest = existingRow.value.latestUserMessageAt; + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + updatedAt: event.occurredAt, + latestUserMessageAt: + event.payload.role === "user" && + (previousLatest === null || event.payload.createdAt > previousLatest) + ? event.payload.createdAt + : previousLatest, + }); + return; + } + case "thread.proposed-plan-upserted": case "thread.activity-appended": case "thread.approval-response-requested": diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index de5af576a..32324847f 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -8,7 +8,7 @@ import { type OrchestrationProjectShell, type OrchestrationShellSnapshot, type OrchestrationThreadShell, - type PullRequestDetail, + type PullRequestSummary, type ServerSettings, type ServerSettingsPatch, } from "@t3tools/contracts"; @@ -105,56 +105,24 @@ function makeSnapshot( }; } -function makePullRequestDetail(input: { +function makePullRequestSummary(input: { readonly projectId: ProjectId; readonly repository: string; readonly number: number; readonly state: "open" | "closed" | "merged"; readonly updatedAt?: string; -}): PullRequestDetail { +}): PullRequestSummary { return { provider: "github", - capabilities: { - diff: true, - comment: true, - actions: [], - mergeMethods: [], - search: true, - review: { inlineComment: true, reply: true, resolve: true, verdicts: [] }, - reviewers: { request: true, listCandidates: true }, - }, - viewerPermissions: { - actions: [], - comment: true, - resolve: true, - verdicts: [], - requestReviewers: true, - }, projectId: input.projectId, - projectTitle: "Linked project", - workspaceRoot: "/workspace/linked", repository: input.repository, number: input.number, title: "Pull request", - body: "", url: `https://example.test/${input.repository}/pull/${input.number}`, - author: null, state: input.state, - isDraft: false, - mergeability: "mergeable", - additions: 0, - deletions: 0, - changedFiles: 0, headBranch: "feature", baseBranch: "main", - createdAt: "2026-08-01T00:00:00.000Z", updatedAt: input.updatedAt ?? NOW, - mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null, - closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null, - reviewers: [], - labels: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, }; } @@ -162,7 +130,7 @@ interface HarnessOptions { readonly snapshot: OrchestrationShellSnapshot; readonly settings?: ServerSettings; readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; - readonly pullRequestDetail?: PullRequestService["Service"]["detail"]; + readonly pullRequestSummary?: PullRequestService["Service"]["summary"]; readonly onDispatch?: ( command: AutoSettleCommand, ) => Effect.Effect; @@ -179,13 +147,14 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const branchCalls = yield* Ref.make< ReadonlyArray<{ readonly cwd: string; readonly branch: string }> >([]); - const detailCalls = yield* Ref.make< + const summaryCalls = yield* Ref.make< ReadonlyArray<{ readonly projectId: ProjectId; readonly repository: string; readonly number: number; }> >([]); + const summaryRecovery = yield* Ref.make>([]); const updateSettings = (patch: ServerSettingsPatch) => Effect.gen(function* () { @@ -200,18 +169,23 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), ); - const pullRequestDetail: PullRequestService["Service"]["detail"] = (input) => - Ref.update(detailCalls, (calls) => [...calls, input]).pipe( - Effect.andThen( - options.pullRequestDetail?.(input) ?? + const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) => + Effect.gen(function* () { + yield* Ref.update(summaryCalls, (calls) => [...calls, input]); + yield* Ref.update(summaryRecovery, (values) => [ + ...values, + readOptions?.recoverTransientFailure, + ]); + return yield* ( + options.pullRequestSummary?.(input, readOptions) ?? Effect.succeed( - makePullRequestDetail({ + makePullRequestSummary({ ...input, state: "open", }), - ), - ), - ); + ) + ); + }); const dispatch: OrchestrationEngineShape["dispatch"] = (command) => { if (command.type !== "thread.auto-settle") { @@ -246,7 +220,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: ), }), Layer.mock(GitManager)({ branchPullRequest }), - Layer.mock(PullRequestService)({ detail: pullRequestDetail }), + Layer.mock(PullRequestService)({ summary: pullRequestSummary }), Layer.mock(OrchestrationEngineService)({ readEvents: () => Stream.empty, dispatch, @@ -265,7 +239,8 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: snapshotReads, commands, branchCalls, - detailCalls, + summaryCalls, + summaryRecovery, updateSettings, layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)), }; @@ -313,8 +288,8 @@ describe("ThreadSettlementReactor", () => { [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], ), branchPullRequest: () => Effect.succeed(null), - pullRequestDetail: (input) => - Effect.succeed(makePullRequestDetail({ ...input, state: "closed" })), + pullRequestSummary: (input) => + Effect.succeed(makePullRequestSummary({ ...input, state: "closed" })), }); yield* Effect.gen(function* () { @@ -345,9 +320,10 @@ describe("ThreadSettlementReactor", () => { assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ { cwd: "/workspace/project", branch: "inactive-feature" }, ]); - assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 }, ]); + assert.deepStrictEqual(yield* Ref.get(fixture.summaryRecovery), [false]); }).pipe(Effect.provide(fixture.layer)); }), ), @@ -483,10 +459,10 @@ describe("ThreadSettlementReactor", () => { ], [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], ), - pullRequestDetail: () => + pullRequestSummary: () => Effect.fail( new PullRequestOperationError({ - operation: "detail", + operation: "summary", detail: "host unavailable", }), ), @@ -500,7 +476,7 @@ describe("ThreadSettlementReactor", () => { (yield* Ref.get(fixture.commands)).map((command) => command.threadId), [ThreadId.make("inactive-without-pr")], ); - assert.strictEqual((yield* Ref.get(fixture.detailCalls)).length, 1); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); }).pipe(Effect.provide(fixture.layer)); }), ), @@ -524,8 +500,8 @@ describe("ThreadSettlementReactor", () => { ], [makeProject(LINKED_PROJECT_ID, "/workspace/linked")], ), - pullRequestDetail: (input) => - Effect.succeed(makePullRequestDetail({ ...input, state: "open" })), + pullRequestSummary: (input) => + Effect.succeed(makePullRequestSummary({ ...input, state: "open" })), }); yield* Effect.gen(function* () { @@ -533,7 +509,7 @@ describe("ThreadSettlementReactor", () => { yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); - assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 }, ]); assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); @@ -572,8 +548,8 @@ describe("ThreadSettlementReactor", () => { ], ), branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }), - pullRequestDetail: (input) => - Effect.succeed(makePullRequestDetail({ ...input, state: "merged" })), + pullRequestSummary: (input) => + Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), }); yield* Effect.gen(function* () { @@ -583,7 +559,7 @@ describe("ThreadSettlementReactor", () => { assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ { cwd: "/workspace/project-root", branch: "saved-feature" }, ]); - assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 }, ]); assert.deepStrictEqual( diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index fd4486a9c..edd52a8ab 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -69,12 +69,18 @@ export const make = Effect.gen(function* () { if (!projects.has(thread.linkedPullRequest.projectId)) { return yield* Effect.die(new Error("linked pull request project not found")); } - const detail = yield* pullRequests.detail({ - projectId: thread.linkedPullRequest.projectId, - repository: thread.linkedPullRequest.repository, - number: thread.linkedPullRequest.number, - }); - return { state: detail.state, updatedAt: detail.updatedAt } satisfies SettlementPullRequest; + const summary = yield* pullRequests.summary( + { + projectId: thread.linkedPullRequest.projectId, + repository: thread.linkedPullRequest.repository, + number: thread.linkedPullRequest.number, + }, + { recoverTransientFailure: false }, + ); + return { + state: summary.state, + updatedAt: summary.updatedAt, + } satisfies SettlementPullRequest; } if (thread.branch === null) return null; const project = projects.get(thread.projectId); diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index 8f1e3e898..beaad93d5 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -170,19 +170,3 @@ export function requireThreadAbsent(input: { ), ); } - -export function requireNonNegativeInteger(input: { - readonly commandType: OrchestrationCommand["type"]; - readonly field: string; - readonly value: number; -}): Effect.Effect { - if (Number.isInteger(input.value) && input.value >= 0) { - return Effect.void; - } - return Effect.fail( - invariantError( - input.commandType, - `${input.field} must be an integer greater than or equal to 0.`, - ), - ); -} diff --git a/apps/server/src/pathExpansion.ts b/apps/server/src/pathExpansion.ts index c9f46b4e2..be2dd99cc 100644 --- a/apps/server/src/pathExpansion.ts +++ b/apps/server/src/pathExpansion.ts @@ -2,6 +2,8 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import type * as Path from "effect/Path"; + /** * Expand a leading `~` (or `~/…`, `~\…`) in a user-supplied path to the * current user's home directory. Spawned processes don't get shell @@ -40,3 +42,19 @@ export function resolveProviderHomePath(value: string): string { ? NodePath.resolve(expanded) : NodePath.resolve(NodeOS.homedir(), expanded); } + +/** + * Same expansion as `expandHomePath`, but joins with a caller-supplied + * `Path.Path` service instead of `node:path`. Use this inside Effect code that + * already has `Path.Path` in context so the platform layer stays in control of + * separator handling. + */ +export function expandHomePathWith(value: string, path: Path.Path): string { + if (value === "~") { + return NodeOS.homedir(); + } + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(NodeOS.homedir(), value.slice(2)); + } + return value; +} diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts deleted file mode 100644 index 52e4f8f74..000000000 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** @deprecated Compatibility alias for the excluded orchestration integration harness. */ -export { layer as ProviderSessionRuntimeRepositoryLive } from "../ProviderSessionRuntime.ts"; diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index ec1ffdefa..41d8f5baf 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -18,7 +18,7 @@ type Loader = { }; const defaultSqliteClientLoaders = { bun: () => import("@effect/sql-sqlite-bun/SqliteClient"), - node: () => import("../NodeSqliteClient.ts"), + node: () => import("@t3tools/shared/nodeSqliteClient"), } satisfies Record Promise>; const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* ( diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index e7d35dd17..43134b49c 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -1,16 +1,15 @@ /** - * MigrationsLive - Migration runner with inline loader + * Migration runner with an inline loader. * * Uses Migrator.make with fromRecord to define migrations inline. * All migrations are statically imported - no dynamic file system loading. * - * Migrations run automatically when the MigrationLayer is provided, - * ensuring the database schema is always up-to-date before the application starts. + * `runMigrations` is called by the SQLite persistence layer at startup, so the + * schema is always up to date before the application starts. */ import * as Migrator from "effect/unstable/sql/Migrator"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; // Import all migrations statically import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; @@ -193,22 +192,3 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({ : Effect.log("Migrations ran successfully").pipe(Effect.annotateLogs({ migrations })); return executedMigrations; }); - -/** - * Layer that runs migrations when the layer is built. - * - * Use this to ensure migrations run before your application starts. - * Migrations are run automatically - no separate script is needed. - * - * @example - * ```typescript - * import { MigrationsLive } from "@acme/db/Migrations" - * import * as SqliteClient from "@acme/db/SqliteClient" - * - * // Migrations run automatically when SqliteClient is provided - * const AppLayer = MigrationsLive.pipe( - * Layer.provideMerge(SqliteClient.layer({ filename: "database.sqlite" })) - * ) - * ``` - */ -export const MigrationsLive = Layer.effectDiscard(runMigrations()); diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts index 1e64519ff..b63af1772 100644 --- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts index 2011613a9..040a9fa47 100644 --- a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts +++ b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts index 71dfe6fd0..49585fb36 100644 --- a/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts +++ b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts index 752b1676e..efdf88bf6 100644 --- a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts +++ b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts index 5160b4ab3..558183e21 100644 --- a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts +++ b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts index 5c0d7e2a7..b5e4f5cf3 100644 --- a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts +++ b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts b/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts index 4b0aa186c..7078450c9 100644 --- a/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts +++ b/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts b/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts index cb50dc2c6..63eba11aa 100644 --- a/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts +++ b/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts index 755591201..0e7f54812 100644 --- a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts +++ b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionProjectFaviconPath.test.ts b/apps/server/src/persistence/Migrations/042_ProjectionProjectFaviconPath.test.ts index 70d170a29..ec2818ead 100644 --- a/apps/server/src/persistence/Migrations/042_ProjectionProjectFaviconPath.test.ts +++ b/apps/server/src/persistence/Migrations/042_ProjectionProjectFaviconPath.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/043_ProjectionThreadSessionLifecycle.test.ts b/apps/server/src/persistence/Migrations/043_ProjectionThreadSessionLifecycle.test.ts index fdaae169e..687ed17a5 100644 --- a/apps/server/src/persistence/Migrations/043_ProjectionThreadSessionLifecycle.test.ts +++ b/apps/server/src/persistence/Migrations/043_ProjectionThreadSessionLifecycle.test.ts @@ -6,7 +6,7 @@ import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const legacyRow = { threadId: "thread-existing", diff --git a/apps/server/src/persistence/Migrations/044_RepairProjectsDefaultThreadEnvMode.test.ts b/apps/server/src/persistence/Migrations/044_RepairProjectsDefaultThreadEnvMode.test.ts index f7b02fe44..8e276271f 100644 --- a/apps/server/src/persistence/Migrations/044_RepairProjectsDefaultThreadEnvMode.test.ts +++ b/apps/server/src/persistence/Migrations/044_RepairProjectsDefaultThreadEnvMode.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/045_AuthSessionClientConnection.test.ts b/apps/server/src/persistence/Migrations/045_AuthSessionClientConnection.test.ts index 59afa10a4..5b250140d 100644 --- a/apps/server/src/persistence/Migrations/045_AuthSessionClientConnection.test.ts +++ b/apps/server/src/persistence/Migrations/045_AuthSessionClientConnection.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/046_ProjectionThreadLinkedPullRequest.test.ts b/apps/server/src/persistence/Migrations/046_ProjectionThreadLinkedPullRequest.test.ts index c233b6164..e1eeb536f 100644 --- a/apps/server/src/persistence/Migrations/046_ProjectionThreadLinkedPullRequest.test.ts +++ b/apps/server/src/persistence/Migrations/046_ProjectionThreadLinkedPullRequest.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/047_ProjectionThreadsUnsettledAt.test.ts b/apps/server/src/persistence/Migrations/047_ProjectionThreadsUnsettledAt.test.ts index e2b1f4016..9a0b6161e 100644 --- a/apps/server/src/persistence/Migrations/047_ProjectionThreadsUnsettledAt.test.ts +++ b/apps/server/src/persistence/Migrations/047_ProjectionThreadsUnsettledAt.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/048_ProjectionThreadSessionPendingTurnRequest.test.ts b/apps/server/src/persistence/Migrations/048_ProjectionThreadSessionPendingTurnRequest.test.ts index 821a71c42..b6aae941a 100644 --- a/apps/server/src/persistence/Migrations/048_ProjectionThreadSessionPendingTurnRequest.test.ts +++ b/apps/server/src/persistence/Migrations/048_ProjectionThreadSessionPendingTurnRequest.test.ts @@ -10,7 +10,7 @@ import { OrchestrationProjectionPipeline } from "../../orchestration/Services/Pr import { OrchestrationEventStoreLive } from "../Layers/OrchestrationEventStore.ts"; import { runMigrations } from "../Migrations.ts"; import migration048 from "./048_ProjectionThreadSessionPendingTurnRequest.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const persistenceLayer = NodeSqliteClient.layerMemory(); const layer = it.layer( diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.test.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.test.ts index 7c6fcf62c..5c0c1621d 100644 --- a/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.test.ts +++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadSessionPendingStop.test.ts @@ -6,7 +6,7 @@ import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; it.layer(NodeServices.layer)("049_ProjectionThreadSessionPendingStop", (it) => { it.effect("adds the exact pending-stop target columns and remains idempotent", () => diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 4ea9cd8b9..bcade3b2b 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -15,7 +15,6 @@ import { ClaudeSettings, ProviderDriverKind, - type ServerProvider, type ServerProviderUsageLimits, } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; @@ -52,7 +51,7 @@ import { type ProviderDriver, type ProviderInstance, } from "../ProviderDriver.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, @@ -66,6 +65,7 @@ import { type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; +import { discoverClaudeSkills } from "./ClaudeSkills.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -114,22 +114,6 @@ export type ClaudeDriverEnv = | ServerConfig | ServerSettingsService; -const withInstanceIdentity = - (input: { - readonly instanceId: ProviderInstance["instanceId"]; - readonly displayName: string | undefined; - readonly accentColor: string | undefined; - readonly continuationGroupKey: string; - }) => - (snapshot: ServerProviderDraft): ServerProvider => ({ - ...snapshot, - instanceId: input.instanceId, - driver: DRIVER_KIND, - ...(input.displayName ? { displayName: input.displayName } : {}), - ...(input.accentColor ? { accentColor: input.accentColor } : {}), - continuation: { groupKey: input.continuationGroupKey }, - }); - export const ClaudeDriver: ProviderDriver = { driverKind: DRIVER_KIND, metadata: { @@ -162,6 +146,7 @@ export const ClaudeDriver: ProviderDriver = { const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); const stampIdentity = withInstanceIdentity({ instanceId, + driverKind: DRIVER_KIND, displayName, accentColor, continuationGroupKey, @@ -287,6 +272,17 @@ export const ClaudeDriver: ProviderDriver = { }), ), ); + const snapshotForCwd = (cwd: string) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + discoverClaudeSkills(effectiveConfig, cwd, processEnv), + ]).pipe( + Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); return { instanceId, @@ -299,6 +295,7 @@ export const ClaudeDriver: ProviderDriver = { accentColor, enabled, snapshot, + snapshotForCwd, adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts new file mode 100644 index 000000000..99074c8b0 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; + +const SKILLS = new Set(["implement", "review", "re-release-version"]); + +describe("planClaudeSkillDispatch", () => { + it("leaves a prompt without a known skill untouched", () => { + expect(planClaudeSkillDispatch("fix the build", SKILLS)).toBeUndefined(); + // Not a discovered skill, so it stays prose rather than becoming a command. + expect(planClaudeSkillDispatch("echo $HOME then $unknown", SKILLS)).toBeUndefined(); + }); + + it("moves a mid-prompt mention into a trailing slash command", () => { + expect(planClaudeSkillDispatch("ok, now $implement all the tickets", SKILLS)).toEqual({ + leadingText: "ok, now", + commandText: "/implement all the tickets", + skillName: "implement", + }); + }); + + it("keeps a mention that opens the prompt as a single command block", () => { + expect(planClaudeSkillDispatch("$review\nfocus on auth", SKILLS)).toEqual({ + leadingText: undefined, + commandText: "/review\nfocus on auth", + skillName: "review", + }); + }); + + it("dispatches the last mention and rewrites earlier ones inline", () => { + expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({ + leadingText: "/review the diff, then", + commandText: "/implement the fixes", + skillName: "implement", + }); + }); + + it("ignores a dollar token glued to other text", () => { + expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts new file mode 100644 index 000000000..a008e0f9e --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -0,0 +1,78 @@ +/** + * ClaudeSkillDispatch — turns `$skill` mentions in a composer prompt into the + * slash invocation Claude Code actually runs. + * + * The composer inserts `$name` for every provider. Codex parses that natively; + * Claude Code does not, and treats it as prose. Claude Code's only user-side + * invocation is a text block whose first character is `/`: the harness + * expands `/name args` into the SKILL.md body, and every character after the + * name (newlines included) arrives as `ARGUMENTS`. Verified against the CLI in + * stream-json mode, which is what the Agent SDK uses: + * + * - The check runs on the LAST text block of the message. Earlier text + * blocks are preserved verbatim, and image blocks may sit before it. + * - Leading whitespace, or a `/name` that starts a later line of the same + * block, is literal text. + * - Only one skill expands per message; a second `/x` becomes argument text + * (anthropics/claude-code#87113). The model still starts the rest through + * its Skill tool when it reads `/name` in the prompt, so earlier mentions + * are rewritten to `/name` inline. + * + * So one mention anywhere in the prompt becomes a guaranteed invocation, and + * the user's text on either side is kept in order. + * + * @module provider/Drivers/ClaudeSkillDispatch + */ + +/** + * Same token shape the composer and timeline chips recognise + * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a + * dispatched skill are always the same set. + */ +const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; + +export interface ClaudeSkillDispatch { + /** Text before the dispatched mention, or `undefined` when it opens the prompt. */ + readonly leadingText: string | undefined; + /** `/name` plus the trailing text, ready to be the message's last text block. */ + readonly commandText: string; + readonly skillName: string; +} + +/** + * Split `prompt` around the last `$skill` mention that names a known skill. + * Returns `undefined` when there is nothing to dispatch, in which case the + * prompt should go out unchanged. Mentions that do not match a discovered + * skill stay literal: a `$HOME` in prose must not become a command. + */ +export function planClaudeSkillDispatch( + prompt: string, + skillNames: ReadonlySet, +): ClaudeSkillDispatch | undefined { + const mentions = [...prompt.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => { + const name = match[2] ?? ""; + if (!skillNames.has(name)) return []; + const start = (match.index ?? 0) + (match[1]?.length ?? 0); + return [{ name, start, end: start + name.length + 1 }]; + }); + const last = mentions.at(-1); + if (!last) { + return undefined; + } + + const leading = prompt.slice(0, last.start); + const trailing = prompt.slice(last.end); + const leadingWithInlineSlashes = mentions + .slice(0, -1) + .reduceRight( + (text, mention) => `${text.slice(0, mention.start)}/${text.slice(mention.start + 1)}`, + leading, + ) + .trimEnd(); + + return { + leadingText: leadingWithInlineSlashes.length > 0 ? leadingWithInlineSlashes : undefined, + commandText: `/${last.name}${trailing}`.trimEnd(), + skillName: last.name, + }; +} diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 60db1d0c5..3e46ba94d 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { discoverClaudeSkills } from "./ClaudeSkills.ts"; +import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts"; const writeSkill = Effect.fn(function* ( skillsDir: string, @@ -66,7 +66,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); - it.effect("discovers project skills from the workspace .agents directory", () => + it.effect("ignores .agents/skills, which Claude Code does not load", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -74,6 +74,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const configDir = path.join(tempDir, "claude-home"); const workspace = path.join(tempDir, "workspace"); + // Verified against the CLI: `/review` here is answered with + // `Unknown command`, so offering it would dispatch a dead command. yield* writeSkill( path.join(workspace, ".agents", "skills"), "review", @@ -82,19 +84,11 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); - assert.deepEqual(skills, [ - { - name: "review", - path: path.join(workspace, ".agents", "skills", "review", "SKILL.md"), - enabled: true, - scope: "project", - description: "Review the changes.", - }, - ]); + assert.deepEqual(skills, []); }), ); - it.effect("prefers workspace .claude skills on three-way name collisions", () => + it.effect("prefers user skills on name collisions even with a stray .agents copy", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -123,49 +117,16 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { assert.deepEqual(skills, [ { name: "deploy", - path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"), + path: path.join(configDir, "skills", "deploy", "SKILL.md"), enabled: true, - scope: "project", - description: "Claude deploy.", - }, - ]); - }), - ); - - it.effect("prefers workspace .agents skills over user skills on name collisions", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); - const configDir = path.join(tempDir, "claude-home"); - const workspace = path.join(tempDir, "workspace"); - - yield* writeSkill( - path.join(configDir, "skills"), - "deploy", - ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), - ); - yield* writeSkill( - path.join(workspace, ".agents", "skills"), - "deploy", - ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"), - ); - - const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); - - assert.deepEqual(skills, [ - { - name: "deploy", - path: path.join(workspace, ".agents", "skills", "deploy", "SKILL.md"), - enabled: true, - scope: "project", - description: "Agents deploy.", + scope: "user", + description: "User deploy.", }, ]); }), ); - it.effect("prefers project skills over user skills on name collisions", () => + it.effect("prefers user skills over project skills on name collisions", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -187,8 +148,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); assert.equal(skills.length, 1); - assert.equal(skills[0]?.scope, "project"); - assert.equal(skills[0]?.description, "Project deploy."); + assert.equal(skills[0]?.scope, "user"); + assert.equal(skills[0]?.description, "User deploy."); }), ); @@ -287,6 +248,424 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("marks skills that only the user can invoke", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "re-release-version", + [ + "---", + "name: re-release-version", + "description: Move the current tag forward.", + "disable-model-invocation: true", + "---", + "", + "# Body", + ].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "release-version", + ["---", "name: release-version", "description: Cut a release.", "---", "", "# Body"].join( + "\n", + ), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.equal( + skills.find((skill) => skill.name === "re-release-version")?.userInvocationOnly, + true, + ); + assert.equal( + skills.find((skill) => skill.name === "release-version")?.userInvocationOnly, + undefined, + ); + }), + ); + + it.effect("disables skills switched off by skillOverrides", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + for (const name of ["kept", "off-by-user", "off-by-project"]) { + yield* writeSkill( + path.join(configDir, "skills"), + name, + ["---", `name: ${name}`, "---", "", "# Body"].join("\n"), + ); + } + + yield* fs.makeDirectory(configDir, { recursive: true }); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{ "skillOverrides": { "off-by-user": "off", "kept": "on" } }', + ); + yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspace, ".claude", "settings.json"), + '{ "skillOverrides": { "off-by-project": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [ + ["kept", true], + ["off-by-project", false], + ["off-by-user", false], + ], + ); + }), + ); + + it.effect("ignores unreadable settings when resolving skillOverrides", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "kept", + ["---", "name: kept", "---", "", "# Body"].join("\n"), + ); + yield* fs.writeFileString(path.join(configDir, "settings.json"), "{ not json"); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [["kept", true]], + ); + }), + ); + + it.effect("treats a user-invocable-only override like disable-model-invocation", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "ask-matt", + ["---", "name: ask-matt", "---", "", "# Body"].join("\n"), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{ "skillOverrides": { "ask-matt": "user-invocable-only" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled, skill.userInvocationOnly === true]), + [["ask-matt", true, true]], + ); + }), + ); + + it.effect("drops every override in a file when one value is invalid, as Claude Code does", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + for (const name of ["unknown-mode", "boolean-false", "sibling-off"]) { + yield* writeSkill( + path.join(configDir, "skills"), + name, + ["---", `name: ${name}`, "---", "", "# Body"].join("\n"), + ); + } + // Verified against the CLI: with an unknown string or a boolean in the + // map, the valid "off" sibling is ignored too and every skill runs. + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{ "skillOverrides": { "unknown-mode": "some-future-mode", "boolean-false": false, "sibling-off": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [ + ["boolean-false", true], + ["sibling-off", true], + ["unknown-mode", true], + ], + ); + }), + ); + + it.effect("reads repository root settings from a nested workspace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const repo = path.join(tempDir, "repo"); + const workspace = path.join(repo, "packages", "app"); + + for (const name of ["root-off", "root-off-cwd-on", "cwd-off-root-on"]) { + yield* writeSkill( + path.join(configDir, "skills"), + name, + ["---", `name: ${name}`, "---", "", "# Body"].join("\n"), + ); + } + yield* fs.makeDirectory(path.join(repo, ".git"), { recursive: true }); + yield* fs.makeDirectory(path.join(repo, ".claude"), { recursive: true }); + yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true }); + // The CLI ignores the root's plain settings.json from a nested cwd. + yield* fs.writeFileString( + path.join(repo, ".claude", "settings.json"), + '{ "skillOverrides": { "cwd-off-root-on": "off" } }', + ); + // The root local file outranks the workspace local file, as in the CLI. + yield* fs.writeFileString( + path.join(repo, ".claude", "settings.local.json"), + '{ "skillOverrides": { "root-off": "off", "root-off-cwd-on": "off", "cwd-off-root-on": "on" } }', + ); + yield* fs.writeFileString( + path.join(workspace, ".claude", "settings.local.json"), + '{ "skillOverrides": { "root-off-cwd-on": "on", "cwd-off-root-on": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [ + ["cwd-off-root-on", true], + ["root-off", false], + ["root-off-cwd-on", false], + ], + ); + }), + ); + + it.effect("ignores ancestor settings outside a repository", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const parent = path.join(tempDir, "not-a-repo"); + const workspace = path.join(parent, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "kept", + ["---", "name: kept", "---", "", "# Body"].join("\n"), + ); + yield* fs.makeDirectory(path.join(parent, ".claude"), { recursive: true }); + yield* fs.makeDirectory(workspace, { recursive: true }); + yield* fs.writeFileString( + path.join(parent, ".claude", "settings.local.json"), + '{ "skillOverrides": { "kept": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [["kept", true]], + ); + }), + ); + + it.effect("lets the administrator's managed policy outrank every other settings file", () => + Effect.gen(function* () { + const path = yield* Path.Path; + + for (const [platform, expected] of [ + ["darwin", "/Library/Application Support/ClaudeCode/managed-settings.json"], + ["linux", "/etc/claude-code/managed-settings.json"], + ] as const) { + const paths = skillOverrideSettingsPaths(path, "/home/.claude", "/workspace", platform, {}); + assert.deepEqual(paths, [ + "/home/.claude/settings.json", + "/workspace/.claude/settings.json", + "/workspace/.claude/settings.local.json", + expected, + ]); + } + + assert.deepEqual( + skillOverrideSettingsPaths(path, "/home/.claude", undefined, "win32", { + PROGRAMDATA: "C:/ProgramData", + }).at(-1), + "C:/ProgramData/ClaudeCode/managed-settings.json", + ); + assert.deepEqual(skillOverrideSettingsPaths(path, "/home/.claude", undefined, "win32", {}), [ + "/home/.claude/settings.json", + ]); + + // Only the repository root's local file joins in, after the + // workspace's own local file so it wins. + assert.deepEqual( + skillOverrideSettingsPaths( + path, + "/home/.claude", + "/repo/packages/app", + "linux", + {}, + "/repo", + ), + [ + "/home/.claude/settings.json", + "/repo/packages/app/.claude/settings.json", + "/repo/packages/app/.claude/settings.local.json", + "/repo/.claude/settings.local.json", + "/etc/claude-code/managed-settings.json", + ], + ); + // A workspace that is the root itself is not read twice. + assert.deepEqual( + skillOverrideSettingsPaths(path, "/home/.claude", "/repo", "linux", {}, "/repo"), + [ + "/home/.claude/settings.json", + "/repo/.claude/settings.json", + "/repo/.claude/settings.local.json", + "/etc/claude-code/managed-settings.json", + ], + ); + }), + ); + + it.effect("records a skill Claude Code keeps out of its own slash commands", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "agent-only", + ["---", "name: agent-only", "user-invocable: false", "---", "", "# Body"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.userInvocable]), + [["agent-only", false]], + ); + }), + ); + + it.effect("identifies a skill by its directory, as Claude Code does", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "probe-alias", + ["---", "name: probe-alias-frontmatter", "---", "", "# Body"].join("\n"), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{ "skillOverrides": { "probe-alias-frontmatter": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + // The frontmatter name is not the command, so an override naming it is + // not the override Claude Code would apply either. + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [["probe-alias", true]], + ); + }), + ); + + it.effect("switches a skill off by its directory name", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "probe-alias", + ["---", "name: probe-alias-frontmatter", "---", "", "# Body"].join("\n"), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{ "skillOverrides": { "probe-alias": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [["probe-alias", false]], + ); + }), + ); + + it.effect("accepts the YAML 1.1 boolean spellings Claude Code allows", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const skillsDir = path.join(configDir, "skills"); + + yield* writeSkill( + skillsDir, + "user-only-yes", + ["---", "disable-model-invocation: yes", "---", "", "# Body"].join("\n"), + ); + yield* writeSkill( + skillsDir, + "agent-only-no", + ["---", "user-invocable: no", "---", "", "# Body"].join("\n"), + ); + yield* writeSkill( + skillsDir, + "plain-off", + ["---", "disable-model-invocation: off", "---", "", "# Body"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [ + skill.name, + skill.userInvocationOnly === true, + skill.userInvocable === false, + ]), + [ + ["agent-only-no", false, true], + ["plain-off", false, false], + ["user-only-yes", true, false], + ], + ); + }), + ); + it.effect("returns an empty list when no skill roots exist", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 83dbe2112..d69ef5464 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -1,10 +1,12 @@ /** * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker. * - * Claude Code loads skills from `/skills` (user scope), then - * `/.agents/skills` and `/.claude/skills` (project scope), one - * directory per skill with a `SKILL.md` carrying YAML frontmatter. Later roots - * win on name collisions, so precedence is user, `.agents`, then `.claude`. + * Claude Code loads skills from `/skills` (user scope) and + * `/.claude/skills` (project scope), one directory per skill with a + * `SKILL.md` carrying YAML frontmatter. The user root wins on name collisions, + * matching the CLI. `.agents/skills` is a Codex location: verified against the + * CLI, a skill that lives only there is answered with `Unknown command`, so it + * is not scanned here. * The Agent SDK init handshake surfaces skills only as slash commands without * their filesystem paths, so the provider snapshot scans the same locations * directly, mirroring how the Codex app-server reports its skills. @@ -17,6 +19,9 @@ import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { parse as parseYamlDocument } from "yaml"; import { resolveProviderHomePath } from "../../pathExpansion.ts"; @@ -28,7 +33,41 @@ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; type SkillFrontmatter = | { readonly kind: "missing" } | { readonly kind: "malformed" } - | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; + | { + readonly kind: "parsed"; + readonly description?: string; + readonly userInvocationOnly?: boolean; + readonly userInvocable?: boolean; + }; + +/** + * Claude Code accepts the YAML 1.1 boolean spellings (`yes`/`no`, `on`/`off`, + * `1`/`0`), which the 1.2 core schema this parser uses leaves as strings and + * numbers. Verified against the CLI: a skill carrying `user-invocable: no` is + * absent from its published slash commands, so a strict `=== false` here would + * offer a command the CLI rejects. + */ +function parseFrontmatterBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value === "number") { + return value === 1 ? true : value === 0 ? false : undefined; + } + if (typeof value !== "string") return undefined; + switch (value.trim().toLowerCase()) { + case "true": + case "yes": + case "on": + case "y": + return true; + case "false": + case "no": + case "off": + case "n": + return false; + default: + return undefined; + } +} function parseSkillFrontmatter(contents: string): SkillFrontmatter { const match = FRONTMATTER_PATTERN.exec(contents); @@ -47,15 +86,187 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { } const record = parsed as Record; - const name = typeof record.name === "string" ? record.name.trim() : ""; const description = typeof record.description === "string" ? record.description.trim() : ""; return { kind: "parsed", - ...(name ? { name } : {}), ...(description ? { description } : {}), + ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true + ? { userInvocationOnly: true } + : {}), + ...(parseFrontmatterBoolean(record["user-invocable"]) === false + ? { userInvocable: false } + : {}), }; } +/** + * Where an administrator installs the policy file whose settings outrank every + * user and project one. Absent on almost every machine, which is why a missing + * file is the normal case rather than an error. + */ +export function claudeManagedSettingsPath( + path: Path.Path, + platform: NodeJS.Platform, + environment: NodeJS.ProcessEnv, +): string | undefined { + if (platform === "darwin") { + return "/Library/Application Support/ClaudeCode/managed-settings.json"; + } + if (platform === "win32") { + const programData = environment.PROGRAMDATA?.trim(); + return programData ? path.join(programData, "ClaudeCode", "managed-settings.json") : undefined; + } + return "/etc/claude-code/managed-settings.json"; +} + +/** + * Settings files Claude Code merges for `skillOverrides`, in increasing + * precedence: user, project, project-local, then the administrator's managed + * policy, which wins outright. When the workspace sits inside a git + * repository, the repository root's `settings.local.json` is read too and + * outranks the workspace's own local file. Verified against the CLI from a + * nested cwd: a root local file switching a skill off wins over a cwd one + * switching it on, the root's plain `settings.json` is not consulted, and + * without a `.git` above the cwd no root file is read. A skill the user + * switched off is reported disabled rather than dropped, so the picker can + * grey it out instead of silently losing it. + */ +export function skillOverrideSettingsPaths( + path: Path.Path, + configDirPath: string, + cwd: string | undefined, + platform: NodeJS.Platform, + environment: NodeJS.ProcessEnv, + repositoryRoot?: string, +): ReadonlyArray { + const managedPath = claudeManagedSettingsPath(path, platform, environment); + const root = repositoryRoot !== undefined && repositoryRoot !== cwd ? repositoryRoot : undefined; + return [ + path.join(configDirPath, "settings.json"), + ...(cwd + ? [ + path.join(cwd, ".claude", "settings.json"), + path.join(cwd, ".claude", "settings.local.json"), + ] + : []), + ...(root ? [path.join(root, ".claude", "settings.local.json")] : []), + ...(managedPath ? [managedPath] : []), + ]; +} + +/** + * Nearest ancestor of `cwd` (inclusive) holding a `.git` entry, which is the + * boundary Claude Code walks up to for project settings. `undefined` outside + * a repository. + */ +const findRepositoryRoot = Effect.fn("findRepositoryRoot")(function* ( + cwd: string, +): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let current = path.resolve(cwd); + while (true) { + const isRoot = yield* fileSystem + .exists(path.join(current, ".git")) + .pipe(Effect.orElseSucceed(() => false)); + if (isRoot) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } +}); + +/** + * The four states Claude Code accepts. The CLI validates the whole map, not + * each entry: verified against it, one entry with an unknown value (or a + * boolean) makes it drop every override in that file, so this schema does the + * same rather than applying the valid siblings the CLI ignores. + */ +const SkillOverrideValue = Schema.Literals(["on", "name-only", "user-invocable-only", "off"]); + +// Lenient because these settings files are hand-edited and Claude Code itself +// tolerates comments and trailing commas in them. +const SkillOverrideSettings = fromLenientJson( + Schema.Struct({ + skillOverrides: Schema.optional(Schema.Record(Schema.String, SkillOverrideValue)), + }), +); +const decodeSkillOverrideSettings = Schema.decodeUnknownEffect(SkillOverrideSettings); + +/** + * What a `skillOverrides` entry says about one skill. `"user-invocable-only"` + * hides it from the agent exactly as `disable-model-invocation` does, so it is + * kept apart from a plain on/off decision rather than collapsed into one. + */ +type SkillOverride = { + readonly enabled: boolean; + readonly userInvocationOnly: boolean; +}; + +function parseSkillOverride(value: typeof SkillOverrideValue.Type): SkillOverride { + switch (value) { + case "off": + return { enabled: false, userInvocationOnly: false }; + case "user-invocable-only": + return { enabled: true, userInvocationOnly: true }; + case "on": + case "name-only": + return { enabled: true, userInvocationOnly: false }; + } +} + +const readSkillOverrides = Effect.fn("readSkillOverrides")(function* ( + configDirPath: string, + cwd: string | undefined, + environment: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const overridesByName = new Map(); + const repositoryRoot = cwd === undefined ? undefined : yield* findRepositoryRoot(cwd); + + for (const settingsPath of skillOverrideSettingsPaths( + path, + configDirPath, + cwd, + platform, + environment, + repositoryRoot, + )) { + const contents = yield* fileSystem + .readFileString(settingsPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (contents === undefined) { + continue; + } + + const parsed = yield* decodeSkillOverrideSettings(contents).pipe( + Effect.tapError((cause) => + Effect.logDebug("claude settings file is unreadable; ignoring skillOverrides", { + path: settingsPath, + cause, + }), + ), + Effect.orElseSucceed(() => undefined), + ); + const overrides = parsed?.skillOverrides; + if (!overrides) { + continue; + } + + for (const [name, value] of Object.entries(overrides)) { + overridesByName.set(name, parseSkillOverride(value)); + } + } + + return overridesByName; +}); + /** * Resolve the Claude config directory the CLI would use, matching the * precedence the spawned CLI sees: the instance's `homePath` (exported as @@ -85,12 +296,14 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct }); /** - * Enumerate Claude Code skills from the user config dir, workspace - * `.agents/skills`, and workspace `.claude/skills`, in that order. Discovery - * is best-effort: unreadable roots and malformed skill entries are skipped so - * a broken skill never degrades the provider snapshot. On name collisions, - * later roots win: `.agents` beats user and `.claude` beats `.agents`, matching - * Claude Code's resolution. + * Enumerate Claude Code skills from the user config dir and the workspace + * `.claude/skills`. Discovery is best-effort: unreadable roots and malformed + * skill entries are skipped so a broken skill never degrades the provider + * snapshot. Roots are listed highest precedence first and the first hit for a + * name wins, matching Claude Code: verified against the CLI with the same + * skill name in both scopes, the user copy is the one that runs. Reporting the + * project copy instead would attach its invocation metadata to a command + * Claude Code resolves elsewhere. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick, @@ -100,15 +313,11 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd); + const skillOverrides = yield* readSkillOverrides(configDirPath, cwd, environment ?? process.env); const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, - ...(cwd - ? [ - { directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }, - { directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }, - ] - : []), + ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), ]; const skillsByName = new Map(); @@ -134,19 +343,39 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* continue; } - const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim(); + // Claude Code identifies a skill by its directory, not by the + // frontmatter `name`: verified against the CLI, a skill in `probe-alias/` + // declaring `name: probe-alias-frontmatter` is published as + // `probe-alias`, and only `skillOverrides["probe-alias"]` switches it + // off. Keying off the frontmatter name would report a command that does + // not exist and miss the override that disables it. + const name = entry.trim(); if (!name) { continue; } + // First root wins, so a later root never displaces a higher-precedence + // skill of the same name. + if (skillsByName.has(name)) { + continue; + } + + const override = skillOverrides.get(name); + const userInvocationOnly = + (frontmatter.kind === "parsed" && frontmatter.userInvocationOnly === true) || + override?.userInvocationOnly === true; skillsByName.set(name, { name, path: skillPath, - enabled: true, + enabled: override?.enabled ?? true, scope: root.scope, ...(frontmatter.kind === "parsed" && frontmatter.description ? { description: frontmatter.description } : {}), + ...(userInvocationOnly ? { userInvocationOnly: true } : {}), + ...(frontmatter.kind === "parsed" && frontmatter.userInvocable === false + ? { userInvocable: false } + : {}), }); } } diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index b10e0a88a..0f3053c7d 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -24,7 +24,6 @@ import { CodexSettings, ProviderDriverKind, - type ServerProvider, type ServerProviderUsageLimits, } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; @@ -47,7 +46,7 @@ import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import * as ModelManifest from "../ModelManifest.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, @@ -92,28 +91,6 @@ export type CodexDriverEnv = | ServerConfig | ServerSettingsService; -/** - * Stamp instance identity onto a `ServerProvider` snapshot produced by the - * driver-kind-only codex helpers. Once `buildServerProvider` in - * `providerSnapshot.ts` is widened to accept `instanceId`/`driver`, this - * wrapper disappears. - */ -const withInstanceIdentity = - (input: { - readonly instanceId: ProviderInstance["instanceId"]; - readonly displayName: string | undefined; - readonly accentColor: string | undefined; - readonly continuationGroupKey: string; - }) => - (snapshot: ServerProviderDraft): ServerProvider => ({ - ...snapshot, - instanceId: input.instanceId, - driver: DRIVER_KIND, - ...(input.displayName ? { displayName: input.displayName } : {}), - ...(input.accentColor ? { accentColor: input.accentColor } : {}), - continuation: { groupKey: input.continuationGroupKey }, - }); - export const CodexDriver: ProviderDriver = { driverKind: DRIVER_KIND, metadata: { @@ -136,6 +113,7 @@ export const CodexDriver: ProviderDriver = { const continuationIdentity = codexContinuationIdentity(homeLayout); const stampIdentity = withInstanceIdentity({ instanceId, + driverKind: DRIVER_KIND, displayName, accentColor, continuationGroupKey: continuationIdentity.continuationKey, diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 2101664d5..1187b6b03 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -11,7 +11,7 @@ * * @module provider/Drivers/CursorDriver */ -import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { CursorSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -38,7 +38,7 @@ import { type ProviderDriver, type ProviderInstance, } from "../ProviderDriver.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { makeProviderMaintenanceCapabilities, @@ -75,22 +75,6 @@ export type CursorDriverEnv = | ServerConfig | ServerSettingsService; -const withInstanceIdentity = - (input: { - readonly instanceId: ProviderInstance["instanceId"]; - readonly displayName: string | undefined; - readonly accentColor: string | undefined; - readonly continuationGroupKey: string; - }) => - (snapshot: ServerProviderDraft): ServerProvider => ({ - ...snapshot, - instanceId: input.instanceId, - driver: DRIVER_KIND, - ...(input.displayName ? { displayName: input.displayName } : {}), - ...(input.accentColor ? { accentColor: input.accentColor } : {}), - continuation: { groupKey: input.continuationGroupKey }, - }); - export const CursorDriver: ProviderDriver = { driverKind: DRIVER_KIND, metadata: { @@ -115,6 +99,7 @@ export const CursorDriver: ProviderDriver = { }); const stampIdentity = withInstanceIdentity({ instanceId, + driverKind: DRIVER_KIND, displayName, accentColor, continuationGroupKey: continuationIdentity.continuationKey, diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 0b4e957fe..32d6149c3 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -1,4 +1,4 @@ -import { GrokSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -25,7 +25,7 @@ import { type ProviderDriver, type ProviderInstance, } from "../ProviderDriver.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { makeManualOnlyProviderMaintenanceCapabilities, @@ -58,22 +58,6 @@ export type GrokDriverEnv = | ServerConfig | ServerSettingsService; -const withInstanceIdentity = - (input: { - readonly instanceId: ProviderInstance["instanceId"]; - readonly displayName: string | undefined; - readonly accentColor: string | undefined; - readonly continuationGroupKey: string; - }) => - (snapshot: ServerProviderDraft): ServerProvider => ({ - ...snapshot, - instanceId: input.instanceId, - driver: DRIVER_KIND, - ...(input.displayName ? { displayName: input.displayName } : {}), - ...(input.accentColor ? { accentColor: input.accentColor } : {}), - continuation: { groupKey: input.continuationGroupKey }, - }); - export const GrokDriver: ProviderDriver = { driverKind: DRIVER_KIND, metadata: { @@ -97,6 +81,7 @@ export const GrokDriver: ProviderDriver = { }); const stampIdentity = withInstanceIdentity({ instanceId, + driverKind: DRIVER_KIND, displayName, accentColor, continuationGroupKey: continuationIdentity.continuationKey, diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 58ccf5912..4ba0b858c 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -12,7 +12,7 @@ * * @module provider/Drivers/OpenCodeDriver */ -import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { OpenCodeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -40,7 +40,7 @@ import { type ProviderDriver, type ProviderInstance, } from "../ProviderDriver.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, @@ -89,22 +89,6 @@ export type OpenCodeDriverEnv = | ServerConfig | ServerSettingsService; -const withInstanceIdentity = - (input: { - readonly instanceId: ProviderInstance["instanceId"]; - readonly displayName: string | undefined; - readonly accentColor: string | undefined; - readonly continuationGroupKey: string; - }) => - (snapshot: ServerProviderDraft): ServerProvider => ({ - ...snapshot, - instanceId: input.instanceId, - driver: DRIVER_KIND, - ...(input.displayName ? { displayName: input.displayName } : {}), - ...(input.accentColor ? { accentColor: input.accentColor } : {}), - continuation: { groupKey: input.continuationGroupKey }, - }); - export const OpenCodeDriver: ProviderDriver = { driverKind: DRIVER_KIND, metadata: { @@ -127,6 +111,7 @@ export const OpenCodeDriver: ProviderDriver }); const stampIdentity = withInstanceIdentity({ instanceId, + driverKind: DRIVER_KIND, displayName, accentColor, continuationGroupKey: continuationIdentity.continuationKey, diff --git a/apps/server/src/provider/Drivers/instanceIdentity.ts b/apps/server/src/provider/Drivers/instanceIdentity.ts new file mode 100644 index 000000000..2fbc1c4a9 --- /dev/null +++ b/apps/server/src/provider/Drivers/instanceIdentity.ts @@ -0,0 +1,28 @@ +import type { ProviderDriverKind, ServerProvider } from "@t3tools/contracts"; + +import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; + +/** + * Stamp instance identity onto a `ServerProvider` snapshot produced by the + * driver-kind-only snapshot helpers. Every driver builds its snapshot without + * knowing its own instance, so it pipes the draft through this stamper before + * publishing. Once `buildServerProvider` in `providerSnapshot.ts` is widened to + * accept `instanceId`/`driver`, this wrapper disappears. + */ +export const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly driverKind: ProviderDriverKind; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: input.driverKind, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index c8277a5bb..35a517219 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -273,6 +273,30 @@ async function readFirstPromptMessage( return next.value; } +/** Drains the first `count` queued prompts so consecutive turns can be compared. */ +async function readPromptMessages( + input: + | { + readonly prompt: AsyncIterable; + } + | undefined, + count: number, +): Promise> { + const iterator = input?.prompt[Symbol.asyncIterator](); + if (!iterator) { + return []; + } + const messages: Array = []; + while (messages.length < count) { + const next = await iterator.next(); + if (next.done) { + break; + } + messages.push(next.value); + } + return messages; +} + const THREAD_ID = ThreadId.make("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); const SYNTHETIC_SUBAGENT_MODEL = "claude-synthetic-subagent[expanded]"; @@ -793,10 +817,100 @@ describe("ClaudeAdapterLive", () => { const promptMessage = yield* Effect.promise(() => readFirstPromptMessage(createInput)); assert.isDefined(promptMessage); assert.deepEqual(promptMessage?.message.content, [ + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "AQIDBA==", + }, + }, { type: "text", text: "What's in this image?", }, + ]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + // The Claude CLI reads a streamed user message as a slash-command invocation + // only when the final content block is text. Leading with the text block sent + // every image-carrying turn down the plain-prompt path, so `/skill args` + // reached the agent unexpanded with no error anywhere. + it.effect("puts the command text last so attachments do not suppress expansion", () => { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-")); + const harness = makeHarness({ + cwd: "/tmp/project-claude-command-attachments", + baseDir, + }); + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => + NodeFS.rmSync(baseDir, { + recursive: true, + force: true, + }), + ), + ); + + const adapter = yield* ClaudeAdapter; + const { attachmentsDir } = yield* ServerConfig; + + const imageAttachment = { + type: "image" as const, + id: "thread-claude-attachment-22345678-1234-1234-1234-123456789abc", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 4, + }; + const fileAttachment = { + type: "file" as const, + id: "thread-claude-attachment-32345678-1234-1234-1234-123456789abc", + name: "notes.pdf", + mimeType: "application/pdf", + sizeBytes: 4, + }; + for (const attachment of [imageAttachment, fileAttachment]) { + const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)!); + NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); + NodeFS.writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4])); + } + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "/flow-patterns hello", + attachments: [], + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "/flow-patterns hello", + attachments: [imageAttachment], + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "/flow-patterns hello", + attachments: [fileAttachment], + }); + + const prompts = yield* Effect.promise(() => + readPromptMessages(harness.getLastCreateQueryInput(), 3), + ); + const commandBlock = { + type: "text" as const, + text: "/flow-patterns hello", + }; + + assert.deepEqual(prompts[0]?.message.content, [commandBlock]); + assert.deepEqual(prompts[1]?.message.content, [ { type: "image", source: { @@ -805,6 +919,51 @@ describe("ClaudeAdapterLive", () => { data: "AQIDBA==", }, }, + commandBlock, + ]); + // Non-image attachments never become content blocks. Claude reaches them + // through the path line ProviderService writes into the prompt, so the + // text block stays last on its own. + assert.deepEqual(prompts[2]?.message.content, [commandBlock]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("dispatches a $skill mention as a trailing slash command block", () => { + // Claude Code only runs `/name` from the message's last text block, so a + // chip picked mid-prompt is moved there and the surrounding prose kept. + const homeDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-skills-home-")); + NodeFS.mkdirSync(NodePath.join(homeDir, "skills", "implement"), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(homeDir, "skills", "implement", "SKILL.md"), + "---\ndescription: Implement the tickets.\n---\n# Body\n", + ); + const harness = makeHarness({ claudeConfig: { homePath: homeDir } }); + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(homeDir, { recursive: true, force: true })), + ); + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "ok, now $implement all the tickets\nstart with auth", + attachments: [], + }); + + const promptMessage = yield* Effect.promise(() => + readFirstPromptMessage(harness.getLastCreateQueryInput()), + ); + assert.deepEqual(promptMessage?.message.content, [ + { type: "text", text: "ok, now" }, + { type: "text", text: "/implement all the tickets\nstart with auth" }, ]); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), @@ -812,6 +971,98 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps the skill command block after image attachments", () => { + // A command block followed by an image is not expanded by the CLI; the + // image must come first. + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-skill-image-")); + const homeDir = NodePath.join(baseDir, "claude-home"); + NodeFS.mkdirSync(NodePath.join(homeDir, "skills", "review"), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(homeDir, "skills", "review", "SKILL.md"), + "---\ndescription: Review.\n---\n# Body\n", + ); + const harness = makeHarness({ baseDir, claudeConfig: { homePath: homeDir } }); + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(baseDir, { recursive: true, force: true })), + ); + const adapter = yield* ClaudeAdapter; + const { attachmentsDir } = yield* ServerConfig; + const attachment = { + type: "image" as const, + id: "thread-claude-attachment-12345678-1234-1234-1234-123456789abc", + name: "diagram.png", + mimeType: "image/png", + sizeBytes: 4, + }; + const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)!); + NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); + NodeFS.writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4])); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "$review this screenshot", + attachments: [attachment], + }); + + const promptMessage = yield* Effect.promise(() => + readFirstPromptMessage(harness.getLastCreateQueryInput()), + ); + assert.isDefined(promptMessage); + const blocks = promptMessage.message.content as Array<{ type: string; text?: string }>; + assert.deepEqual( + blocks.map((block) => (block.type === "text" ? block.text : block.type)), + ["image", "/review this screenshot"], + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("leaves a $ mention of an unknown or disabled skill as prose", () => { + const homeDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-skills-off-")); + NodeFS.mkdirSync(NodePath.join(homeDir, "skills", "deploy"), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(homeDir, "skills", "deploy", "SKILL.md"), + "---\ndescription: Deploy.\n---\n# Body\n", + ); + NodeFS.writeFileSync( + NodePath.join(homeDir, "settings.json"), + JSON.stringify({ skillOverrides: { deploy: "off" } }), + ); + const harness = makeHarness({ claudeConfig: { homePath: homeDir } }); + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(homeDir, { recursive: true, force: true })), + ); + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "run $deploy and echo $HOME", + attachments: [], + }); + + const promptText = yield* Effect.promise(() => + readFirstPromptText(harness.getLastCreateQueryInput()), + ); + assert.equal(promptText, "run $deploy and echo $HOME"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -2112,6 +2363,12 @@ describe("ClaudeAdapterLive", () => { resultContextWindow: 200_000, }); + // The usage event is offered to the runtime stream after the turn + // closes, so drain until both snapshots land rather than racing the + // interrupt against the second one. + for (let attempt = 0; attempt < 200 && usageSnapshots.length < 2; attempt += 1) { + yield* Effect.yieldNow; + } yield* Fiber.interrupt(usageFiber); // The query is session-scoped: two turns, one call. diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 3668dd97b..ce560a351 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -81,6 +81,8 @@ import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts"; +import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, @@ -1313,13 +1315,20 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( readonly attachmentsDir: string; readonly boundInstanceId: ProviderInstanceId; readonly modelCatalog: ClaudeModelCatalog; + /** Names of the skills Claude Code can run for this session's cwd. */ + readonly skillNames: ReadonlySet; }, ) { const text = buildPromptText(input, dependencies.boundInstanceId, dependencies.modelCatalog); const sdkContent: Array> = []; - if (text.length > 0) { - sdkContent.push({ type: "text", text }); + // Claude Code expands a skill only from the LAST text block, and only when + // `/name` is its first character. A `$skill` chip anywhere in the prompt is + // therefore split into [leading text, "/name trailing text"] so the CLI + // runs it natively and the prose around it survives. See ClaudeSkillDispatch. + const dispatch = planClaudeSkillDispatch(text, dependencies.skillNames); + if (dispatch?.leadingText !== undefined) { + sdkContent.push({ type: "text", text: dispatch.leadingText }); } for (const attachment of input.attachments ?? []) { @@ -1369,6 +1378,17 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( ); } + // The final text block goes last on purpose. The Claude CLI only reads a + // streamed user message as a slash-command invocation when the last content + // block is text; image blocks ahead of it ride along as preceding input. + // Leading with the text made every image-carrying turn fall back to a plain + // prompt, so a hand-typed `/skill args` reached the agent unexpanded. + if (dispatch) { + sdkContent.push({ type: "text", text: dispatch.commandText }); + } else if (text.length > 0) { + sdkContent.push({ type: "text", text }); + } + return buildUserMessage({ sdkContent }); }); @@ -4730,11 +4750,29 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } + // Re-scan on every send: skills are added and switched off mid-session, + // and the scan is a few directory reads. A skill switched off via + // skillOverrides, or reserved for the agent with `user-invocable: false`, + // is left as prose: the CLI would answer `/name` with a notice instead of + // running it. + const skills = yield* discoverClaudeSkills( + claudeSettings, + context.session.cwd, + claudeEnvironment, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); const message = yield* buildUserMessageEffect(input, { fileSystem, attachmentsDir: serverConfig.attachmentsDir, boundInstanceId, modelCatalog, + skillNames: new Set( + skills + .filter((skill) => skill.enabled && skill.userInvocable !== false) + .map((skill) => skill.name), + ), }); yield* Queue.offer(context.promptQueue, { diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 66fad0677..ba648266f 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -860,6 +860,9 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { ); assert.isDefined(permissionResponse); + const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); + assert.deepStrictEqual(argvRuns, [["--force", "acp"]]); + yield* adapter.stopSession(threadId); }), ); @@ -1300,7 +1303,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); assert.lengthOf(argvRuns, 1, "session should not restart — only one spawn"); - assert.deepStrictEqual(argvRuns[0], ["acp"]); + assert.deepStrictEqual(argvRuns[0], ["--force", "acp"]); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); const setConfigRequests = requests.filter( diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 7a7db51c1..f40cfa802 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -550,6 +550,7 @@ export function makeCursorAdapter( ...(options?.environment ? { environment: options.environment } : {}), childProcessSpawner, cwd, + runtimeMode: input.runtimeMode, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, ...(mcpSession diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index ed84fab99..6ec2c4d87 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -3,8 +3,10 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import { beforeEach } from "vite-plus/test"; import { OpenCodeSettings } from "@t3tools/contracts"; @@ -34,6 +36,7 @@ const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; const runtimeMock = { state: { runVersionError: null as Error | null, + runVersionPending: false, versionStdout: DEFAULT_VERSION_STDOUT, inventoryError: null as Error | null, connectionError: null as Error | null, @@ -52,6 +55,7 @@ const runtimeMock = { }, reset() { this.state.runVersionError = null; + this.state.runVersionPending = false; this.state.versionStdout = DEFAULT_VERSION_STDOUT; this.state.inventoryError = null; this.state.connectionError = null; @@ -114,15 +118,17 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }; }), runOpenCodeCommand: () => - runtimeMock.state.runVersionError - ? Effect.fail( - new OpenCodeRuntimeError({ - operation: "runOpenCodeCommand", - detail: runtimeMock.state.runVersionError.message, - cause: runtimeMock.state.runVersionError, - }), - ) - : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), + runtimeMock.state.runVersionPending + ? Effect.never + : runtimeMock.state.runVersionError + ? Effect.fail( + new OpenCodeRuntimeError({ + operation: "runOpenCodeCommand", + detail: runtimeMock.state.runVersionError.message, + cause: runtimeMock.state.runVersionError, + }), + ) + : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), createOpenCodeSdkClient: (input) => { runtimeMock.state.sdkClientInputs.push(input); return {} as unknown as ReturnType; @@ -216,6 +222,24 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect("times out a hanging local CLI version probe", () => + Effect.gen(function* () { + runtimeMock.state.runVersionPending = true; + const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("4 seconds"); + const snapshot = yield* Fiber.join(probeFiber); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => Effect.gen(function* () { runtimeMock.state.inventory = { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index af7563cf2..5183a68d0 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -32,9 +32,10 @@ const OPENCODE_PRESENTATION = { showInteractionModeToggle: false, supportsConversationRollback: false, } as const; +const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds"; class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ - readonly cause: unknown; + readonly cause?: unknown; readonly detail: string; }> {} @@ -171,7 +172,30 @@ function inferDefaultAgent(agents: ReadonlyArray): string | undefined { } const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [], + optionDescriptors: [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + ], + currentValue: "medium", + }, + { + id: "agent", + label: "Agent", + type: "select", + options: [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ], + currentValue: "build", + }, + ], }); function openCodeCapabilitiesForModel(input: { @@ -179,7 +203,14 @@ function openCodeCapabilitiesForModel(input: { readonly model: ProviderListResponse["all"][number]["models"][string]; readonly agents: ReadonlyArray; }): ModelCapabilities { - const variantValues = Object.keys(input.model.variants ?? {}); + const rawVariantValues = Object.keys(input.model.variants ?? {}); + // When a model advertises no variants, synthesize the standard reasoning + // levels so the composer still offers a Reasoning selector (mirrors the + // Codex/Grok experience where reasoning is always configurable). The set + // covers the common OpenCode variant spectrum; `inferDefaultVariant` + // picks the provider-appropriate default (e.g. medium for openai/opencode). + const variantValues = + rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"]; const defaultVariant = inferDefaultVariant(input.providerID, variantValues); const variantOptions = variantValues.map((value) => defaultVariant === value @@ -201,7 +232,7 @@ function openCodeCapabilitiesForModel(input: { ? [ { id: "variant", - label: "Variant", + label: "Reasoning", type: "select" as const, options: variantOptions, ...(defaultVariant ? { currentValue: defaultVariant } : {}), @@ -400,6 +431,15 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu Effect.mapError( (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), ), + Effect.timeoutOrElse({ + duration: OPENCODE_VERSION_PROBE_TIMEOUT, + orElse: () => + Effect.fail( + new OpenCodeProbeError({ + detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, + }), + ), + }), ), ); if (versionExit._tag === "Failure") { diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts index 944ab026c..9a8bd078a 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts @@ -735,24 +735,6 @@ export const makeProviderInstanceRegistry = (input: { return { registry, mutator }; }); -/** - * Assemble a `ProviderInstanceRegistry` Layer bound to a fixed set of - * drivers and a pre-resolved `ProviderInstanceConfigMap`. Used by tests - * that want explicit control over the registry's source-of-truth without - * wiring up the settings watcher. - * - * Only exposes the public registry tag — hot-reload consumers should use - * `ProviderInstanceRegistryMutableLayer` (below) or the hydration layer. - */ -export const ProviderInstanceRegistryLayer = (input: { - readonly drivers: ReadonlyArray>; - readonly configMap: ProviderInstanceConfigMap; -}): Layer.Layer => - Layer.effect( - ProviderInstanceRegistry, - makeProviderInstanceRegistry(input).pipe(Effect.map((built) => built.registry)), - ) as Layer.Layer; - /** * Layer variant that also exposes the mutator tag. Consumed by * `ProviderInstanceRegistryHydrationLive` to reconcile on settings diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3f1d79e61..ac0de27d1 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -792,6 +792,44 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("drops custom models the refreshed snapshot no longer carries", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2.1.0", + models: [ + { + slug: "claude-sonnet-4-6", + name: "Sonnet 4.6", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-custom", + name: "removed-custom", + isCustom: true, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [previousProvider.models[0]], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...refreshedProvider.models, + ]); + }); + it("drops stale OpenCode models missing from a successful refresh", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 7e09e7744..c1b515b5e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -122,9 +122,13 @@ const mergeProviderModels = ( nextModels: ReadonlyArray, ): ReadonlyArray => { const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider); + // Custom rows are derived from settings and every snapshot carries the full + // current list, so a custom model missing from `nextModels` was removed by + // the user and must not be resurrected from the previous snapshot. + const retainablePreviousModels = previousModels.filter((model) => !model.isCustom); - if (shouldRetainMissingModels && nextModels.length === 0 && previousModels.length > 0) { - return previousModels; + if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) { + return retainablePreviousModels; } const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const)); @@ -140,7 +144,7 @@ const mergeProviderModels = ( }); const nextSlugs = new Set(nextModels.map((model) => model.slug)); return shouldRetainMissingModels - ? [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))] + ? [...mergedModels, ...retainablePreviousModels.filter((model) => !nextSlugs.has(model.slug))] : mergedModels; }; @@ -165,24 +169,6 @@ export const mergeProviderSnapshot = ( : {}), }; -export const mergeProviderSnapshots = ( - previousProviders: ReadonlyArray, - nextProviders: ReadonlyArray, -): ReadonlyArray => { - const mergedProviders = new Map( - previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), - ); - - for (const provider of nextProviders) { - mergedProviders.set( - snapshotInstanceKey(provider), - mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider), - ); - } - - return orderProviderSnapshots([...mergedProviders.values()]); -}; - export interface ProviderCapacityOverlayBackend { readonly backend: ServerProviderBackend; readonly retentionIdentity?: string | undefined; @@ -233,12 +219,6 @@ export function mergeProviderCapacityRefresh(input: { }); } -export const selectProvidersByKind = ( - providers: ReadonlyArray, - providerKinds: ReadonlySet, -): ReadonlyArray => - providers.filter((provider) => providerKinds.has(provider.driver)); - export const haveProvidersChanged = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index eed24a4c1..b30b27672 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -522,7 +522,11 @@ const hasMetricSnapshot = ( Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), ); -function makeProviderServiceLayer() { +function makeProviderServiceLayer( + input: { + readonly directory?: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; + } = {}, +) { const startReservationCounts: number[] = []; const codex = makeFakeCodexAdapter(); const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); @@ -543,7 +547,10 @@ function makeProviderServiceLayer() { const rollbackRepositoryLayer = RollbackSagaRepositoryLive.pipe( Layer.provide(SqlitePersistenceMemory), ); - const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const directoryLayer = + input.directory === undefined + ? ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)) + : Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, input.directory); const layer = it.layer( Layer.mergeAll( @@ -3875,6 +3882,54 @@ validation.layer("ProviderServiceLive validation", (it) => { ); }); +const activeSessionThreadId = asThreadId("thread-active-session"); +const historicalSessionThreadId = asThreadId("thread-historical-session"); +const listThreadIds = vi.fn(() => + Effect.succeed([activeSessionThreadId, historicalSessionThreadId]), +); +const getBinding = vi.fn((threadId: ThreadId) => + Effect.succeed( + Option.some({ + threadId, + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + }), + ), +); +const boundedListing = makeProviderServiceLayer({ + directory: { + upsert: () => Effect.void, + getProvider: () => Effect.die("ProviderService.listSessions does not use getProvider"), + removeExact: () => Effect.die("ProviderService.listSessions does not use removeExact"), + getBinding, + listThreadIds, + listBindings: () => Effect.die("ProviderService.listSessions does not use listBindings"), + }, +}); + +boundedListing.layer("ProviderServiceLive session listing", (it) => { + it.effect("looks up bindings for active sessions without scanning historical threads", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + yield* boundedListing.codex.startSession({ + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId: activeSessionThreadId, + cwd: "/tmp/project-active-session", + runtimeMode: "full-access", + }); + listThreadIds.mockClear(); + getBinding.mockClear(); + + const sessions = yield* provider.listSessions(); + + assert.equal(sessions.length, 1); + assert.equal(listThreadIds.mock.calls.length, 0); + assert.deepEqual(getBinding.mock.calls, [[activeSessionThreadId]]); + }), + ); +}); + describe("agent browser access", () => { const revokedThreads: Array = []; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index ab8f47e0a..66bc20dbc 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -2768,21 +2768,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ); const activeSessions = sessionsByProvider.flatMap((sessions) => sessions); - const persistedBindings = yield* directory.listThreadIds().pipe( - Effect.flatMap((threadIds) => - Effect.forEach( - threadIds, - (threadId) => - directory - .getBinding(threadId) - .pipe( - Effect.orElseSucceed(() => - Option.none(), - ), - ), - { concurrency: "unbounded" }, - ), - ), + // Only live adapter sessions appear in this response. Resolving every + // historical binding here makes each call scale with the full thread + // history instead of the active session set. + const persistedBindings = yield* Effect.forEach( + [...new Set(activeSessions.map((session) => session.threadId))], + (threadId) => + directory + .getBinding(threadId) + .pipe( + Effect.orElseSucceed(() => + Option.none(), + ), + ), + { concurrency: "unbounded" }, + ).pipe( Effect.orElseSucceed( () => [] as Array>, ), diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index b2bffae3b..625e6857c 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -243,7 +243,3 @@ export const ProviderSessionDirectoryLive = Layer.effect( ProviderSessionDirectory, makeProviderSessionDirectory, ); - -export function makeProviderSessionDirectoryLive() { - return Layer.effect(ProviderSessionDirectory, makeProviderSessionDirectory); -} diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index a3524717d..48592f184 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -25,6 +25,7 @@ import type { ProviderDriverKind, ProviderInstanceEnvironment, ProviderInstanceId, + ServerProvider, ServerProviderBackend, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; @@ -80,6 +81,7 @@ export interface ProviderInstance { readonly accentColor?: string | undefined; readonly enabled: boolean; readonly snapshot: ServerProviderShape; + readonly snapshotForCwd?: (cwd: string) => Effect.Effect; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; /** diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index ce4bc8b8e..3e89af9ee 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -12,11 +12,15 @@ import { type ProviderRuntimeEvent, type RuntimeRequestId, type ThreadId, - type ToolLifecycleItemType, type TurnId, } from "@t3tools/contracts"; -import type { AcpPermissionRequest, AcpPlanUpdate, AcpToolCallState } from "./AcpRuntimeModel.ts"; +import { + type AcpPermissionRequest, + type AcpPlanUpdate, + type AcpToolCallState, + canonicalItemTypeFromAcpToolKind, +} from "./AcpRuntimeModel.ts"; type AcpAdapterRawSource = Extract< RuntimeEventRawSource, @@ -58,22 +62,6 @@ function canonicalRequestTypeFromAcpKind(kind: string | "unknown"): AcpCanonical } } -function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { - switch (kind) { - case "execute": - return "command_execution"; - case "edit": - case "delete": - case "move": - return "file_change"; - case "search": - case "fetch": - return "web_search"; - default: - return "dynamic_tool_call"; - } -} - function runtimeItemStatusFromAcpToolStatus( status: AcpToolCallState["status"], ): "inProgress" | "completed" | "failed" | undefined { diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index 2a8045c58..25bae26ed 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -432,7 +432,11 @@ function normalizeToolKind(kind: unknown): string | undefined { return typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : undefined; } -function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { +/** + * Map an ACP tool kind onto the canonical runtime item type used by the + * thread activity model. Unknown kinds fall back to a generic tool call. + */ +export function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { switch (kind) { case "execute": return "command_execution"; diff --git a/apps/server/src/provider/acp/CursorAcpSupport.test.ts b/apps/server/src/provider/acp/CursorAcpSupport.test.ts index a095fdd67..93c1da9c5 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.test.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.test.ts @@ -74,6 +74,33 @@ describe("buildCursorAcpSpawnInput", () => { cwd: "/tmp/project", }); }); + + it("forces approval in full-access mode", () => { + expect(buildCursorAcpSpawnInput(undefined, "/tmp/project", undefined, "full-access")).toEqual({ + command: "cursor-agent", + args: ["--force", "acp"], + cwd: "/tmp/project", + }); + }); + + it("uses Cursor auto-review in auto mode", () => { + expect(buildCursorAcpSpawnInput(undefined, "/tmp/project", undefined, "auto")).toEqual({ + command: "cursor-agent", + args: ["--auto-review", "acp"], + cwd: "/tmp/project", + }); + }); + + it.each(["approval-required", "auto-accept-edits"] as const)( + "does not relax approval in %s mode", + (runtimeMode) => { + expect(buildCursorAcpSpawnInput(undefined, "/tmp/project", undefined, runtimeMode)).toEqual({ + command: "cursor-agent", + args: ["acp"], + cwd: "/tmp/project", + }); + }, + ); }); describe("applyCursorAcpModelSelection", () => { diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts index 30203ad77..e3f741d34 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -1,4 +1,8 @@ -import { type CursorSettings, type ProviderOptionSelection } from "@t3tools/contracts"; +import { + type CursorSettings, + type ProviderOptionSelection, + type RuntimeMode, +} from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -15,6 +19,17 @@ import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; type CursorAcpRuntimeCursorSettings = Pick; +function cursorAcpPermissionArgs(runtimeMode?: RuntimeMode): ReadonlyArray { + switch (runtimeMode) { + case "auto": + return ["--auto-review"]; + case "full-access": + return ["--force"]; + default: + return []; + } +} + export interface CursorAcpRuntimeInput extends Omit< AcpSessionRuntime.AcpSessionRuntimeOptions, "authMethodId" | "clientCapabilities" | "spawn" @@ -22,6 +37,7 @@ export interface CursorAcpRuntimeInput extends Omit< readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined; readonly environment?: NodeJS.ProcessEnv; + readonly runtimeMode?: RuntimeMode; } export interface CursorAcpModelSelectionErrorContext { @@ -34,11 +50,13 @@ export function buildCursorAcpSpawnInput( cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined, cwd: string, environment?: NodeJS.ProcessEnv, + runtimeMode?: RuntimeMode, ): AcpSessionRuntime.AcpSpawnInput { return { command: cursorSettings?.binaryPath || "cursor-agent", args: [ ...(cursorSettings?.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), + ...cursorAcpPermissionArgs(runtimeMode), "acp", ], cwd, @@ -57,7 +75,12 @@ export const makeCursorAcpRuntime = ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, - spawn: buildCursorAcpSpawnInput(input.cursorSettings, input.cwd, input.environment), + spawn: buildCursorAcpSpawnInput( + input.cursorSettings, + input.cwd, + input.environment, + input.runtimeMode, + ), authMethodId: "cursor_login", clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, }).pipe( diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index f1bd02a5b..8076fce25 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -556,11 +556,23 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); const child = yield* spawner.spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { + detached: hostPlatform !== "win32", shell: spawnCommand.shell, ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.environment ? { env: input.environment } : { extendEnv: true }), }), ); + const terminateCommandGroup = + hostPlatform === "win32" + ? child.kill({ killSignal: "SIGKILL" }).pipe(Effect.asVoid) + : Effect.sync(() => { + try { + process.kill(-Number(child.pid), "SIGKILL"); + } catch { + // The command and its process group may already have exited. + } + }); + yield* Effect.addFinalizer(() => terminateCommandGroup.pipe(Effect.ignore)); const collectOptions = input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes }; const [stdout, stderr, code] = yield* Effect.all( diff --git a/apps/server/src/provider/prime/PrimeAgentRecoveryLedger.test.ts b/apps/server/src/provider/prime/PrimeAgentRecoveryLedger.test.ts index dd139cda6..300677b67 100644 --- a/apps/server/src/provider/prime/PrimeAgentRecoveryLedger.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentRecoveryLedger.test.ts @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import migration050 from "../../persistence/Migrations/050_PrimeAgentRecoveryLedger.ts"; -import * as NodeSqliteClient from "../../persistence/NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { make, PRIME_AGENT_RECOVERY_ADOPTION_MAX_ATTEMPTS, diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 232c9e5e9..3791ce1fe 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -219,6 +219,35 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { ); }); + it("does not resurrect cached custom models that settings no longer declare", () => { + const builtIn = { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: emptyCapabilities, + } as const; + const cachedCodex = makeProvider(CODEX_DRIVER, { + models: [ + builtIn, + { + slug: "removed-custom", + name: "removed-custom", + isCustom: true, + capabilities: emptyCapabilities, + }, + ], + }); + const fallbackCodex = makeProvider(CODEX_DRIVER, { models: [builtIn] }); + + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: cachedCodex, + fallbackProvider: fallbackCodex, + }).models, + [builtIn], + ); + }); + it("ignores stale cached enabled state when the provider is now disabled", () => { const cachedCodex = makeProvider(CODEX_DRIVER, { checkedAt: "2026-04-10T12:00:00.000Z", diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index ff5f2c084..afe33d7e5 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -1,5 +1,4 @@ import { - type ProviderDriverKind, type ProviderInstanceId, type ServerProvider, ServerProvider as ServerProviderSchema, @@ -30,7 +29,13 @@ const mergeProviderModels = ( cachedModels: ReadonlyArray, ): ReadonlyArray => { const fallbackSlugs = new Set(fallbackModels.map((model) => model.slug)); - return [...fallbackModels, ...cachedModels.filter((model) => !fallbackSlugs.has(model.slug))]; + // The fallback snapshot is built from current settings and already carries + // every custom model, so cached custom rows that are not in it were removed + // while the cache was stale and must not come back. + return [ + ...fallbackModels, + ...cachedModels.filter((model) => !model.isCustom && !fallbackSlugs.has(model.slug)), + ]; }; export const orderProviderSnapshots = ( @@ -107,23 +112,6 @@ export const resolveProviderStatusCachePath = Effect.fn("resolveProviderStatusCa }, ); -/** - * Legacy kind-keyed path resolver retained for callers that still think in - * terms of `ProviderDriverKind`. Prefer `resolveProviderStatusCachePath` with an - * `instanceId`; new code should route through the instance registry. - * - * @deprecated use `resolveProviderStatusCachePath` with an instance id. - */ -export const resolveLegacyProviderStatusCachePath = Effect.fn( - "resolveLegacyProviderStatusCachePath", -)(function* (input: { - readonly cacheDir: string; - readonly provider: ProviderDriverKind; -}): Effect.fn.Return { - const path = yield* Path.Path; - return path.join(input.cacheDir, `${input.provider}.json`); -}); - export const readProviderStatusCache = ( filePath: string, options?: { readonly configRevision?: string | undefined }, diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index 5baf18a1f..98e21d75b 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -97,6 +97,46 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect("reads an Azure pull request page larger than the VCS default output limit", () => + Effect.gen(function* () { + const rows = pullRequestRows(100, 1).map((row) => ({ + ...row, + description: "x".repeat(10_000), + })); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const response = JSON.stringify(rows); + expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000); + + mockedExecute.mockImplementationOnce((input) => { + const maxOutputBytes = + "maxOutputBytes" in input && typeof input.maxOutputBytes === "number" + ? input.maxOutputBytes + : 1_000_000; + return Effect.succeed( + maxOutputBytes >= Buffer.byteLength(response) + ? output(response) + : { + ...output(response.slice(0, maxOutputBytes)), + stdoutTruncated: true, + }, + ); + }); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "merged", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 99, + }); + + assert.strictEqual(batch.items.length, 99); + assert.isTrue(batch.truncated); + }), + ); + it.effect("reads the page unnarrowed when asked to search, having nothing to search with", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 549a172b3..96dc4ea1d 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -111,6 +111,7 @@ export type AzureDevOpsPullRequestCliError = /** The version every REST call below is pinned to, so a new default cannot reshape a response. */ const REST_API_VERSION = "7.1"; +const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; export class AzureDevOpsPullRequestCli extends Context.Service< AzureDevOpsPullRequestCli, @@ -258,10 +259,15 @@ export const make = Effect.gen(function* () { // how to read all of them. const detectArgs = ["--detect", "true"] as const; - const executeJson = (input: { readonly cwd: string; readonly args: ReadonlyArray }) => + const executeJson = (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly maxOutputBytes?: number; + }) => azure.execute({ cwd: input.cwd, args: [...input.args, "--only-show-errors", "--output", "json"], + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), }); /** @@ -290,6 +296,7 @@ export const make = Effect.gen(function* () { const top = remaining + 1; return executeJson({ cwd: input.cwd, + maxOutputBytes: PULL_REQUEST_LIST_MAX_OUTPUT_BYTES, args: [ "repos", "pr", diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index e7a9a6b6d..70c6184ad 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -84,6 +84,9 @@ function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeReque url: pullRequest.url, author: pullRequest.author, headBranch: pullRequest.headBranch, + ...(pullRequest.headRepositoryNameWithOwner + ? { headRepositoryNameWithOwner: pullRequest.headRepositoryNameWithOwner } + : {}), baseBranch: pullRequest.baseBranch, state: pullRequest.state, isDraft: pullRequest.isDraft, diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index f3bb2941b..de6a09899 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -10,12 +10,14 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; const mockedExecute = vi.fn(); +const mockedGetPullRequest = vi.fn(); const layer = it.layer( GitHubPullRequestCli.layer.pipe( Layer.provide( Layer.mock(GitHubCli.GitHubCli)({ execute: mockedExecute, + getPullRequest: mockedGetPullRequest, }), ), Layer.provide(GitHubGraphQlBudget.layer), @@ -177,9 +179,50 @@ function searchQueryOfCall(index: number): string | undefined { afterEach(() => { mockedExecute.mockReset(); + mockedGetPullRequest.mockReset(); }); layer("GitHubPullRequestCli.layer", (it) => { + it.effect("reads linked pull request status through one narrow request", () => + Effect.gen(function* () { + mockedGetPullRequest.mockReturnValueOnce( + Effect.succeed({ + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + baseRefName: "main", + headRefName: "feat/summary", + state: "open", + updatedAt: "2026-08-24T12:34:56.000Z", + }), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const summary = yield* cli.getPullRequestSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.deepStrictEqual(summary, { + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + headBranch: "feat/summary", + baseBranch: "main", + state: "open", + updatedAt: "2026-08-24T12:34:56.000Z", + }); + expect(mockedGetPullRequest).toHaveBeenCalledOnce(); + expect(mockedGetPullRequest).toHaveBeenCalledWith({ + cwd: "/w", + reference: "https://github.com/acme/web/pull/7", + }); + expect(mockedExecute).not.toHaveBeenCalled(); + }), + ); + it.effect("asks for one row more than the page, to probe for a next page", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index ce8a7e479..8b444b89f 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -123,6 +123,25 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedErrorClass()( + "GitHubPullRequestUpdatedAtUnavailableError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + repository: Schema.String, + number: Schema.Int, + }, +) { + get detail(): string { + return `Pull request ${this.repository}#${this.number} reported no update time.`; + } + + override get message(): string { + return `GitHub CLI failed in getPullRequestSummary: ${this.detail}`; + } +} + /** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ export class GitHubDiffCursorError extends Schema.TaggedErrorClass()( "GitHubDiffCursorError", @@ -250,7 +269,8 @@ export type GitHubPullRequestCliError = | GitHubRepositorySelectorError | GitHubSubjectScopeError | SourceControlRateLimit.SourceControlRateLimitPausedError - | GitHubViewerLoginUnavailableError; + | GitHubViewerLoginUnavailableError + | GitHubPullRequestUpdatedAtUnavailableError; /** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; @@ -360,6 +380,24 @@ export class GitHubPullRequestCli extends Context.Service< }>; }) => Effect.Effect, GitHubPullRequestCliError>; + readonly getPullRequestSummary: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect< + { + readonly number: number; + readonly title: string; + readonly url: string; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: string; + }, + GitHubPullRequestCliError + >; + readonly getPullRequestDetail: (input: { readonly cwd: string; readonly repository: string; @@ -1326,6 +1364,35 @@ export const make = Effect.gen(function* () { ).pipe(Effect.map((results) => results.flat())); }, + getPullRequestSummary: (input) => + github + .getPullRequest({ + cwd: input.cwd, + reference: `https://${input.host}/${input.repository}/pull/${input.number}`, + }) + .pipe( + Effect.flatMap((summary) => + summary.updatedAt === undefined + ? Effect.fail( + new GitHubPullRequestUpdatedAtUnavailableError({ + command: "gh", + cwd: input.cwd, + repository: input.repository, + number: input.number, + }), + ) + : Effect.succeed({ + number: summary.number, + title: summary.title, + url: summary.url, + headBranch: summary.headRefName, + baseBranch: summary.baseRefName, + state: summary.state ?? "open", + updatedAt: summary.updatedAt, + }), + ), + ), + getPullRequestDetail: (input) => github .execute({ diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 2555c05dc..afbeee638 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -7,6 +7,43 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; +it.effect("uses one narrow read for a linked pull request summary", () => + Effect.gen(function* () { + let summaryReads = 0; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestSummary: () => + Effect.sync(() => { + summaryReads += 1; + return { + number: 7, + title: "Summary", + url: "https://github.com/acme/web/pull/7", + headBranch: "feat/summary", + baseBranch: "main", + state: "open" as const, + updatedAt: "2026-08-24T12:34:56.000Z", + }; + }), + }), + ), + ); + + const readSummary = provider.getChangeRequestSummary; + if (readSummary === undefined) return yield* Effect.die("summary read was not implemented"); + const summary = yield* readSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(summary.state).toBe("open"); + expect(summaryReads).toBe(1); + }), +); + describe("gitHubViewerPermissions", () => { it("offers everything to a viewer who can write to the repository", () => { expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({ diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index cc097c30c..ff8f31c81 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -1,4 +1,7 @@ +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import type { PullRequestActor, PullRequestCapabilities, @@ -129,6 +132,22 @@ const rendersEmpty = (body: string): boolean => export const make = Effect.gen(function* () { const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const repositoryAccessCache = yield* Cache.makeWith( + (key: string) => { + const [cwd, repository, host] = JSON.parse(key) as [string, string, string]; + return cli.getRepositoryAccess({ cwd, repository, host }); + }, + { + capacity: 128, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(10) : Duration.zero), + }, + ); + const getRepositoryAccess = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + }) => Cache.get(repositoryAccessCache, JSON.stringify([input.cwd, input.repository, input.host])); + const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => new PullRequestProviderError({ provider: "github", @@ -223,6 +242,9 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("listChangeRequestStats"))), + getChangeRequestSummary: (input) => + cli.getPullRequestSummary(input).pipe(Effect.mapError(fail("getChangeRequestSummary"))), + getChangeRequest: (input) => Effect.all( [ @@ -244,7 +266,7 @@ export const make = Effect.gen(function* () { ), ), ), - cli.getRepositoryAccess({ + getRepositoryAccess({ cwd: input.cwd, repository: input.repository, host: input.host, diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 644f3552c..323553828 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -69,6 +69,7 @@ export interface ProviderChangeRequest { readonly url: string; readonly author: PullRequestActor | null; readonly headBranch: string; + readonly headRepositoryNameWithOwner?: string | null; readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; @@ -86,6 +87,17 @@ export interface ProviderChangeRequest { readonly checksState?: PullRequestChecksState | null | undefined; } +/** The fields needed to keep a linked thread's pull request status live. */ +export interface ProviderChangeRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly updatedAt: string; +} + export interface ProviderChangeRequestPage { readonly items: ReadonlyArray; /** True when the host has more rows than the page size asked for. */ @@ -300,6 +312,14 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; + /** + * The cheap live fields used by linked threads. Optional because a provider without a narrow + * endpoint can fall back to its full detail read at the service boundary. + */ + readonly getChangeRequestSummary?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + /** Comments, line threads, and commits, kept off the critical path for the core detail. */ readonly getChangeRequestActivity: ( input: ProviderRepositoryRef & { readonly number: number }, diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index d142e7368..d688430bd 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2293,6 +2293,105 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o }), ); +it.effect("shares one cold viewer lookup across distinct concurrent lists", () => + Effect.gen(function* () { + let viewerCalls = 0; + let listCalls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getViewer: () => + Effect.sync(() => { + viewerCalls += 1; + }).pipe(Effect.andThen(Effect.yieldNow), Effect.as("bilal")), + listChangeRequests: () => + Effect.sync(() => { + listCalls += 1; + return { items: [], truncated: false, continues: true }; + }), + }), + ], + }); + + yield* Effect.all( + ["all", "authored", "reviewing"].map((involvement) => + service.list({ + state: "open", + involvement: involvement as "all" | "authored" | "reviewing", + }), + ), + { concurrency: "unbounded" }, + ); + + assert.strictEqual(viewerCalls, 1); + assert.strictEqual(listCalls, 3); + }), +); + +it.effect("uses five host reads for the normal indexed-repository page workflow", () => + Effect.gen(function* () { + let viewerCalls = 0; + let searchCalls = 0; + let fallbackCalls = 0; + let statsCalls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getViewer: () => + Effect.sync(() => { + viewerCalls += 1; + return "bilal"; + }), + listChangeRequestsAcross: (input) => + Effect.sync(() => { + searchCalls += 1; + return { + items: + input.involvement === "all" + ? [batchedChangeRequest(1, "acme/web", "2026-07-02T00:00:00Z")] + : [], + truncated: false, + }; + }), + listChangeRequests: () => + Effect.sync(() => { + fallbackCalls += 1; + return { items: [], truncated: false, continues: true }; + }), + listChangeRequestStats: () => + Effect.sync(() => { + statsCalls += 1; + return [{ repository: "acme/web", number: 1, additions: 3, deletions: 1 }]; + }), + }), + ], + }); + + const baseline = yield* service.list({ state: "open", involvement: "all" }); + yield* Effect.all( + [ + service.list({ state: "open", involvement: "authored" }), + service.list({ state: "open", involvement: "reviewing" }), + ], + { concurrency: "unbounded" }, + ); + yield* service.listStats({ + refs: baseline.entries.map(({ projectId, repository, number }) => ({ + projectId, + repository, + number, + })), + }); + + assert.deepStrictEqual( + { viewerCalls, searchCalls, fallbackCalls, statsCalls }, + { viewerCalls: 1, searchCalls: 3, fallbackCalls: 0, statsCalls: 1 }, + ); + }), +); + it.effect("returns the refreshed listing on the first read after its cache expires", () => Effect.gen(function* () { let hostCalls = 0; @@ -2371,10 +2470,15 @@ it.effect("a listing narrowed to some projects is its own cache entry", () => it.effect("an explicit invalidation makes the next listing ask the host again", () => Effect.gen(function* () { let hostCalls = 0; + let viewerCalls = 0; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ fakeProvider("github", { + getViewer: () => { + viewerCalls += 1; + return Effect.succeed("bilal"); + }, listChangeRequests: () => { hostCalls += 1; return Effect.succeed({ items: [], truncated: false, continues: false }); @@ -2387,6 +2491,7 @@ it.effect("an explicit invalidation makes the next listing ask the host again", yield* service.invalidate({}); yield* service.list({ state: "open" }); assert.strictEqual(hostCalls, 2); + assert.strictEqual(viewerCalls, 2); // Forgetting one change request leaves the listings shared. yield* service.invalidate({ @@ -2829,6 +2934,115 @@ it.effect( }), ); +it.effect("shares linked summaries and only recovers transient failures for display reads", () => + Effect.gen(function* () { + let calls = 0; + let failing = false; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequestSummary: () => + Effect.sync(() => { + calls += 1; + return failing; + }).pipe( + Effect.tap(() => Effect.yieldNow), + Effect.flatMap((shouldFail) => + shouldFail + ? Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getChangeRequestSummary", + reason: "failed", + detail: "HTTP 504", + }), + ) + : Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), + ), + ), + }), + ], + }); + + yield* Effect.all( + [ + service.summary(reference, { recoverTransientFailure: false }), + service.summary(reference, { recoverTransientFailure: false }), + ], + { concurrency: "unbounded" }, + ); + assert.strictEqual(calls, 1); + + yield* TestClock.adjust("61 seconds"); + failing = true; + const strict = yield* Effect.flip( + service.summary(reference, { recoverTransientFailure: false }), + ); + assert.strictEqual(strict._tag, "PullRequestOperationError"); + + const stale = yield* service.summary(reference); + assert.strictEqual(stale.updatedAt, "2026-07-02T00:00:00Z"); + assert.strictEqual(calls, 3); + + yield* service.invalidate({ reference }); + const invalidated = yield* Effect.flip(service.summary(reference)); + assert.strictEqual(invalidated._tag, "PullRequestOperationError"); + }), +); + +it.effect("keeps recent detail on a transient refresh failure but not after invalidation", () => + Effect.gen(function* () { + let failing = false; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + failing + ? Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getChangeRequest", + reason: "failed", + detail: "spawn gh EAGAIN", + }), + ) + : Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + body: "last good body", + changedFiles: 2, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: { + actions: ["merge"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }, + }), + }), + ], + }); + + yield* service.detail(reference); + yield* TestClock.adjust("16 seconds"); + failing = true; + const stale = yield* service.detail(reference); + assert.strictEqual(stale.body, "last good body"); + + yield* service.invalidate({ reference }); + const invalidated = yield* Effect.flip(service.detail(reference)); + assert.strictEqual(invalidated._tag, "PullRequestOperationError"); + }), +); + it.effect("carries an armed auto-merge through to the detail, and silence as silence", () => Effect.gen(function* () { const detailWith = (autoMergeEnabled: boolean | undefined) => diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 7cf82cf10..4ef576a07 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -5,6 +5,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { PullRequestOperationError, PullRequestUnavailableError, @@ -38,6 +39,7 @@ import { type PullRequestReviewerCandidateList, type PullRequestReviewerRequestInput, type PullRequestSubmitReviewInput, + type PullRequestSummary, type PullRequestThreadReplyInput, type PullRequestThreadResolutionInput, type PullRequestThreadCommentsInput, @@ -96,6 +98,7 @@ const REPOSITORY_SEARCH_CHUNK = 100; * `invalidate` rather than a flag on the read, so an ordinary read can never opt out. */ const LIST_CACHE_TTL = Duration.seconds(30); +const SUMMARY_CACHE_TTL = Duration.seconds(60); const DETAIL_CACHE_TTL = Duration.seconds(15); const DIFF_CACHE_TTL = Duration.seconds(60); /** A commit is content-addressed, so its own diff cannot change under its key. */ @@ -106,10 +109,14 @@ const LIST_STATS_CACHE_TTL = Duration.seconds(60); const DIFF_STALE_WINDOW = Duration.minutes(10); /** How long one host's signed-in login is believed without asking its CLI again. */ const VIEWER_CACHE_TTL = Duration.minutes(10); +const SEARCH_VISIBILITY_TTL = Duration.minutes(10); +const STALE_DETAIL_WINDOW = Duration.minutes(10); +const isPullRequestProviderError = Schema.is(PullRequestProviderError); const LIST_CACHE_CAPACITY = 64; const LIST_STATS_CACHE_CAPACITY = 32; const DETAIL_CACHE_CAPACITY = 128; const DIFF_CACHE_CAPACITY = 128; +const VIEWER_CACHE_CAPACITY = 32; export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; @@ -122,6 +129,10 @@ export class PullRequestService extends Context.Service< readonly listStats: ( input: PullRequestListStatsInput, ) => Effect.Effect; + readonly summary: ( + input: PullRequestRef, + options?: { readonly recoverTransientFailure?: boolean }, + ) => Effect.Effect; readonly detail: (input: PullRequestRef) => Effect.Effect; readonly activity: ( input: PullRequestRef, @@ -430,6 +441,11 @@ function withRateLimitBackoff( listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats), }), getChangeRequest: wrap("getChangeRequest", api.getChangeRequest), + ...(api.getChangeRequestSummary === undefined + ? {} + : { + getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary), + }), getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity), ...(api.getReviewThreadComments === undefined ? {} @@ -699,6 +715,46 @@ export const make = Effect.gen(function* () { // "is this host set up" answer the provider switcher shows, and holding it would keep saying // signed-out after the reader has signed in. const viewersByHost = new Map(); + const viewerFlights = yield* Cache.makeWith( + (key: string): Effect.Effect => { + const [host, kind, roots] = JSON.parse(key) as [ + string, + SourceControlProviderKind, + ReadonlyArray, + ]; + const registered = registry.get(kind); + if (registered === null) { + return Effect.die(new Error(`Missing pull request provider: ${kind}`)); + } + const api = withRateLimitBackoff(registered, host, rateLimits); + return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe( + Effect.map((viewer) => ({ + host, + kind, + viewer: viewer as string | null, + error: null as PullRequestProviderError | null, + })), + Effect.tap((result) => + Effect.map(Clock.currentTimeMillis, (at) => viewersByHost.set(host, { at, result })), + ), + Effect.catch((error) => + Effect.succeed({ + host, + kind, + viewer: null, + error, + }), + ), + ); + }, + { + capacity: VIEWER_CACHE_CAPACITY, + // The host-wide success map holds the real ten-minute answer. This short entry exists to + // keep simultaneous cold page reads on one in-flight lookup; failures remain retryable. + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value.error === null ? Duration.seconds(1) : Duration.zero, + }, + ); const resolveViewers = ( projects: ReadonlyArray, @@ -718,18 +774,8 @@ export const make = Effect.gen(function* () { // unreadable worktree would otherwise report the whole host as signed out. const roots = viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot); - return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe( - Effect.map((viewer) => ({ - host, - kind: api.kind, - viewer: viewer as string | null, - error: null as PullRequestProviderError | null, - })), - Effect.tap((result) => - Effect.map(Clock.currentTimeMillis, (at) => viewersByHost.set(host, { at, result })), - ), - Effect.catch((error) => Effect.succeed({ host, kind: api.kind, viewer: null, error })), - ); + const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]); + return Cache.get(viewerFlights, key); }), { concurrency: REPOSITORY_CONCURRENCY }, ); @@ -809,6 +855,13 @@ export const make = Effect.gen(function* () { }; }; + // A repository that has appeared in a host search is known to be indexed there. Empty + // authored/reviewing searches for that same repository are therefore real empty answers, not + // a reason to issue the two-command per-repository fallback again. + const searchVisibleAt = new Map(); + const searchVisibilityKey = (host: string, repository: string) => + `${host}\n${repository.trim().toLowerCase()}`; + const listUncached: PullRequestService["Service"]["list"] = (input) => Effect.gen(function* () { const involvement = input.involvement ?? "all"; @@ -1018,61 +1071,80 @@ export const make = Effect.gen(function* () { ? {} : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }), }).pipe( - Effect.flatMap((page) => { - const rows = new Map>(); - for (const item of page.items) { - const key = item.repository.trim().toLowerCase(); - const held = rows.get(key); - if (held === undefined) rows.set(key, [item]); - else held.push(item); - } - // The oldest row of the whole slice, which is how far every repository in it has now - // been read — including the ones that contributed nothing to it. - const boundary = page.items.reduce( - (oldest, item) => - oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest, - null, - ); - return Effect.forEach( - chunk, - (project): Effect.Effect => { - const fetched = rows.get(project.repository.trim().toLowerCase()) ?? []; - // GitHub does not index every repository for search — a renamed one answers for - // its old name with silence rather than with an error — so a repository the - // search said nothing at all about is read on its own, once, before it is - // believed. Only on its first slice: after that it has a boundary to carry on - // from, and silence past one means the rows are older rather than absent. That - // keeps a search-invisible repository from disappearing on a busy host, at the - // price of one request per repository with nothing in the first slice — which - // run together, and only there. - if (fetched.length === 0 && cursorOf(project) === undefined) { - return readRepository(project); + Effect.flatMap((page) => + Effect.flatMap(Clock.currentTimeMillis, (now) => { + const rows = new Map>(); + for (const [key, visibleAt] of searchVisibleAt) { + if (now - visibleAt > Duration.toMillis(SEARCH_VISIBILITY_TTL)) { + searchVisibleAt.delete(key); } - const cursorHere = cursorOf(project); - const items = - cursorHere === undefined - ? fetched - : fetched.filter( - (item) => - item.updatedAt !== cursorHere.updatedBefore || - !cursorHere.seenAt.includes(item.number), - ); - return Effect.succeed({ - key: listCursorKey(project.host, project.repository), - entries: items - .filter((item) => matchesRowFilters(item, input.filters, viewer)) - .map((item) => toEntry({ project, item, viewer })), - errors: [], - truncated: page.truncated, - nextCursor: - page.truncated && boundary !== null - ? listCursorAt(cursorHere, boundary, fetched, items.length) - : null, - }); - }, - { concurrency: REPOSITORY_CONCURRENCY }, - ); - }), + } + for (const item of page.items) { + const key = item.repository.trim().toLowerCase(); + const held = rows.get(key); + if (held === undefined) rows.set(key, [item]); + else held.push(item); + searchVisibleAt.set(searchVisibilityKey(first.host, item.repository), now); + } + // The oldest row of the whole slice, which is how far every repository in it has now + // been read — including the ones that contributed nothing to it. + const boundary = page.items.reduce( + (oldest, item) => + oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest, + null, + ); + return Effect.forEach( + chunk, + (project): Effect.Effect => { + const fetched = rows.get(project.repository.trim().toLowerCase()) ?? []; + // GitHub does not index every repository for search — a renamed one answers for + // its old name with silence rather than with an error — so a repository the + // search said nothing at all about is read on its own, once, before it is + // believed. Only on its first slice: after that it has a boundary to carry on + // from, and silence past one means the rows are older rather than absent. That + // keeps a search-invisible repository from disappearing on a busy host, at the + // price of one request per repository with nothing in the first slice — which + // run together, and only there. + const lastVisible = searchVisibleAt.get( + searchVisibilityKey(project.host, project.repository), + ); + const searchIsKnownVisible = + !page.truncated && + lastVisible !== undefined && + now - lastVisible <= Duration.toMillis(SEARCH_VISIBILITY_TTL); + if ( + fetched.length === 0 && + cursorOf(project) === undefined && + !searchIsKnownVisible + ) { + return readRepository(project); + } + const cursorHere = cursorOf(project); + const items = + cursorHere === undefined + ? fetched + : fetched.filter( + (item) => + item.updatedAt !== cursorHere.updatedBefore || + !cursorHere.seenAt.includes(item.number), + ); + return Effect.succeed({ + key: listCursorKey(project.host, project.repository), + entries: items + .filter((item) => matchesRowFilters(item, input.filters, viewer)) + .map((item) => toEntry({ project, item, viewer })), + errors: [], + truncated: page.truncated, + nextCursor: + page.truncated && boundary !== null + ? listCursorAt(cursorHere, boundary, fetched, items.length) + : null, + }); + }, + { concurrency: REPOSITORY_CONCURRENCY }, + ); + }), + ), Effect.catch(separately), ); }; @@ -1127,6 +1199,39 @@ export const make = Effect.gen(function* () { const viewerOf = (project: SupportedProject): Effect.Effect => resolveViewers([project], new Map()).pipe(Effect.map(([resolved]) => resolved?.viewer ?? null)); + const summaryUncached: PullRequestService["Service"]["summary"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => { + const providerInput = { + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }; + const read = + project.api.getChangeRequestSummary === undefined + ? project.api.getChangeRequest(providerInput) + : project.api.getChangeRequestSummary(providerInput); + return read.pipe( + Effect.mapError(toPullRequestError("summary")), + Effect.map( + (changeRequest): PullRequestSummary => ({ + provider: project.api.kind, + projectId: project.project.id, + repository: project.repository, + number: changeRequest.number, + title: changeRequest.title, + url: changeRequest.url, + state: changeRequest.state, + headBranch: changeRequest.headBranch, + baseBranch: changeRequest.baseBranch, + updatedAt: changeRequest.updatedAt, + }), + ), + ); + }), + ); + const detailUncached: PullRequestService["Service"]["detail"] = (input) => requireProject(input).pipe( Effect.flatMap((project) => @@ -1164,6 +1269,9 @@ export const make = Effect.gen(function* () { deletions: changeRequest.deletions, changedFiles: changeRequest.changedFiles, headBranch: changeRequest.headBranch, + ...(changeRequest.headRepositoryNameWithOwner === undefined + ? {} + : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }), baseBranch: changeRequest.baseBranch, createdAt: changeRequest.createdAt, updatedAt: changeRequest.updatedAt, @@ -1845,6 +1953,50 @@ export const make = Effect.gen(function* () { }; })(); + const makeLastGoodRead = (capacity: number) => { + const held = new Map(); + const record = (key: string, value: A) => + Effect.map(Clock.currentTimeMillis, (at) => { + held.delete(key); + if (held.size >= capacity) { + const oldest = held.keys().next().value; + if (oldest !== undefined) held.delete(oldest); + } + held.set(key, { at, value }); + }); + const read = (key: string, effect: Effect.Effect) => + effect.pipe( + Effect.tap((value) => record(key, value)), + Effect.catchTags({ + PullRequestOperationError: (error) => { + if (!isPullRequestProviderError(error.cause)) { + return Effect.fail(error); + } + const provider = error.cause; + if (provider.reason !== "failed" && provider.reason !== "rate-limited") { + return Effect.fail(error); + } + return Effect.flatMap(Clock.currentTimeMillis, (now) => { + const snapshot = held.get(key); + if ( + snapshot === undefined || + now - snapshot.at > Duration.toMillis(STALE_DETAIL_WINDOW) + ) { + return Effect.fail(error); + } + return Effect.logWarning("using recent pull request data after a failed refresh", { + operation: error.operation, + reason: provider.reason, + }).pipe(Effect.as(snapshot.value)); + }); + }, + }), + ); + return { read, record }; + }; + const lastGoodSummary = makeLastGoodRead(DETAIL_CACHE_CAPACITY); + const lastGoodDetail = makeLastGoodRead(DETAIL_CACHE_CAPACITY); + // Epochs are the invalidation mechanism: a key carries its scope's epoch, so bumping the // epoch strands every entry made under the old one — no enumerating a cache whose keys // (cursors, commits) nothing holds a list of. The counter is shared and monotonic so a @@ -1855,6 +2007,8 @@ export const make = Effect.gen(function* () { const REF_EPOCH_CAPACITY = 2_048; const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`; const refEpoch = (ref: PullRequestRef) => refEpochs.get(refScope(ref)) ?? 0; + const refCacheKey = (ref: PullRequestRef) => + JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]); const bumpRefEpoch = (ref: PullRequestRef) => { const scope = refScope(ref); if (!refEpochs.has(scope) && refEpochs.size >= REF_EPOCH_CAPACITY) { @@ -1881,6 +2035,24 @@ export const make = Effect.gen(function* () { }; }; + const summaryCache = yield* Cache.makeWith( + (key: string) => { + const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; + return summaryUncached({ projectId, repository, number } as PullRequestRef); + }, + { + capacity: DETAIL_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), + }, + ); + const summary: PullRequestService["Service"]["summary"] = (input, options) => { + const key = refCacheKey(input); + const cached = Cache.get(summaryCache, key); + return options?.recoverTransientFailure === false + ? cached.pipe(Effect.tap((value) => lastGoodSummary.record(key, value))) + : lastGoodSummary.read(key, cached); + }; + // Keys serialize positionally and parse back in the lookup, so the cache is the only holder // of in-flight state: concurrent identical reads coalesce on the key into one host request. // The continuation cursors are part of the key, entries sorted so one continuation is one @@ -1969,8 +2141,8 @@ export const make = Effect.gen(function* () { }, ); const detail: PullRequestService["Service"]["detail"] = (input) => { - const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); - return Cache.get(detailCache, key); + const key = refCacheKey(input); + return lastGoodDetail.read(key, Cache.get(detailCache, key)); }; const activityCache = yield* Cache.makeWith( @@ -1984,7 +2156,7 @@ export const make = Effect.gen(function* () { }, ); const activity: PullRequestService["Service"]["activity"] = (input) => { - const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); + const key = refCacheKey(input); return Cache.get(activityCache, key); }; @@ -2056,17 +2228,16 @@ export const make = Effect.gen(function* () { return Cache.get(listStatsCache, key); }; - const invalidate: PullRequestService["Service"]["invalidate"] = (input) => - Effect.sync(() => { - if (input.reference === undefined) { - listingsEpoch = ++epochCounter; - // A whole-workspace refresh is the reader asking to be re-answered from the hosts, - // and that includes who the hosts say they are. - viewersByHost.clear(); - return; - } - bumpRefEpoch(input.reference); - }); + const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { + const reference = input.reference; + if (reference !== undefined) { + return Effect.sync(() => bumpRefEpoch(reference)); + } + return Effect.sync(() => { + listingsEpoch = ++epochCounter; + viewersByHost.clear(); + }).pipe(Effect.andThen(Cache.invalidateAll(viewerFlights))); + }; // A mutation's own client re-reads right after it, and every other client's next read must // see the action too — so a write forgets the change request it touched and the listings its @@ -2088,6 +2259,7 @@ export const make = Effect.gen(function* () { return PullRequestService.of({ list, listStats, + summary, detail, activity, threadComments, diff --git a/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts b/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts index a348ac4b3..356c5da96 100644 --- a/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts @@ -24,7 +24,7 @@ function pullRequest(overrides: Record = {}): Record { url: "https://bitbucket.org/acme/web/pull-requests/897", author: { login: "bilal", name: "Bilal Hassan" }, headBranch: "feat/page", + headRepositoryNameWithOwner: "fork/web", baseBranch: "master", state: "open", isDraft: false, diff --git a/apps/server/src/pullRequest/bitbucketPullRequestJson.ts b/apps/server/src/pullRequest/bitbucketPullRequestJson.ts index b0711b8ff..f0bc8a71a 100644 --- a/apps/server/src/pullRequest/bitbucketPullRequestJson.ts +++ b/apps/server/src/pullRequest/bitbucketPullRequestJson.ts @@ -52,6 +52,7 @@ const RawUserSchema = Schema.Struct({ */ const RawBranchSchema = Schema.Struct({ branch: Schema.Struct({ name: TrimmedNonEmptyString }), + repository: Schema.optional(Schema.NullOr(Schema.Struct({ full_name: TrimmedNonEmptyString }))), }); const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) }); @@ -177,6 +178,7 @@ export interface BitbucketPullRequest { readonly url: string; readonly author: PullRequestActor | null; readonly headBranch: string; + readonly headRepositoryNameWithOwner: string | null; readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; @@ -291,6 +293,7 @@ function toPullRequest(raw: Schema.Schema.Type): Bi url: raw.links.html.href, author: toActor(raw.author), headBranch: raw.source.branch.name, + headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null, baseBranch: raw.destination.branch.name, state: toState(raw), isDraft: raw.draft ?? false, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 59e97525b..4a43230c7 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -17,6 +17,8 @@ import { KeybindingRule, MessageId, ExternalLauncherCommandNotFoundError, + OrchestrationShellSnapshot, + type OrchestrationShellStreamItem, OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, type OrchestrationThreadActivity, @@ -59,11 +61,11 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; -import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; import { ChildProcessSpawner } from "effect/unstable/process"; import { FetchHttpClient, @@ -83,26 +85,9 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationThreadDetailSnapshot), ); - -const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function* ( - queue: Queue.Queue, - predicate: (value: A) => boolean, - waitDescription: string, -) { - return yield* Effect.gen(function* () { - const values: A[] = []; - while (true) { - const value = yield* Queue.take(queue); - values.push(value); - if (predicate(value)) return values; - } - }).pipe( - Effect.timeoutOrElse({ - duration: "10 seconds", - orElse: () => Effect.die(new Error(`Timed out waiting for ${waitDescription}`)), - }), - ); -}); +const decodeTransferShellSnapshot = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationShellSnapshot), +); import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; @@ -159,6 +144,7 @@ import * as VcsDriver from "./vcs/VcsDriver.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; @@ -178,16 +164,19 @@ import * as Data from "effect/Data"; import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; import { - countingWsRpcProtocolLayer, - makeCountingWsRpcClient, - makeWebSocketTransferRecorder, measureHttpGet, + openMeasuredWsClient, transferDelta, } from "../integration/NetworkTransferMeasurement.integration.ts"; +import { makeSqlStatementCounter } from "../integration/SqlStatementCounter.integration.ts"; import { + awaitSubscriptionSynchronized, + collectQueueUntil, expectedMeasuredAssistantText, queueMeasuredTransferTurn, seedTransferBudgetHistory, + subscribeShellItems, + subscribeThreadItems, TRANSFER_HISTORY_TURN_COUNT, TRANSFER_MEASURED_TURN_CREATED_AT, TRANSFER_MEASURED_TURN_INDEX, @@ -1071,6 +1060,7 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), + Layer.provide(VcsProcess.layer), Layer.provide(layerConfig), ); @@ -8613,6 +8603,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { bootstrapGitOperations.push("fetch"); }), ); + const remoteBranchExists = vi.fn( + (_: Parameters[0]) => + Effect.sync(() => { + bootstrapGitOperations.push("remote-branch-exists"); + return true; + }), + ); const fetchedOriginCommit = "0123456789abcdef0123456789abcdef01234567"; const resolveRemoteTrackingCommit = vi.fn( (_: Parameters[0]) => @@ -8656,6 +8653,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitVcsDriver: { remoteExists, fetchRemote, + remoteBranchExists, resolveRemoteTrackingCommit, createWorktree, }, @@ -8739,6 +8737,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { cwd: "/tmp/project", remoteName: "origin", }); + assert.deepEqual(remoteBranchExists.mock.calls[0]?.[0], { + cwd: "/tmp/project", + remoteName: "origin", + refName: "main", + }); assert.deepEqual(resolveRemoteTrackingCommit.mock.calls[0]?.[0], { cwd: "/tmp/project", refName: "main", @@ -8747,6 +8750,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(bootstrapGitOperations, [ "remote-exists", "fetch", + "remote-branch-exists", "resolve-remote-commit", "create-worktree", ]); @@ -8774,108 +8778,122 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect( - "falls back to the local base branch when startFromOrigin is set but no origin remote exists", - () => - Effect.gen(function* () { - const dispatchedCommands: Array = []; - const remoteExists = vi.fn( - (_: Parameters[0]) => - Effect.succeed(false), - ); - const fetchRemote = vi.fn( - (_: Parameters[0]) => Effect.void, - ); - const resolveRemoteTrackingCommit = vi.fn( - (_: Parameters[0]) => - Effect.succeed({ - commitSha: "0123456789abcdef0123456789abcdef01234567", - remoteRefName: "origin/main", - }), - ); - const createWorktree = vi.fn( - (_: Parameters[0]) => - Effect.succeed({ - worktree: { - refName: "t3code/bootstrap-refName", - path: "/tmp/bootstrap-worktree", - }, - }), - ); - - yield* buildAppUnderTest({ - layers: { - gitVcsDriver: { - remoteExists, - fetchRemote, - resolveRemoteTrackingCommit, - createWorktree, - }, - orchestrationEngine: { - dispatch: (command) => - Effect.sync(() => { - dispatchedCommands.push(command); - return { sequence: dispatchedCommands.length }; - }), - readEvents: () => Stream.empty, + it.effect.each([ + { caseName: "the origin remote is missing", hasOrigin: false }, + { caseName: "the base branch exists only locally", hasOrigin: true }, + ])("falls back to the local base branch when $caseName", ({ hasOrigin }) => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const remoteExists = vi.fn( + (_: Parameters[0]) => + Effect.succeed(hasOrigin), + ); + const fetchRemote = vi.fn( + (_: Parameters[0]) => Effect.void, + ); + const resolveRemoteTrackingCommit = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + commitSha: "0123456789abcdef0123456789abcdef01234567", + remoteRefName: "origin/main", + }), + ); + const remoteBranchExists = vi.fn( + (_: Parameters[0]) => + Effect.succeed(false), + ); + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", }, + }), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + remoteExists, + fetchRemote, + remoteBranchExists, + resolveRemoteTrackingCommit, + createWorktree, }, - }); + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); - const createdAt = "2026-01-01T00:00:00.000Z"; - const wsUrl = yield* getWsServerUrl("/ws"); - yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-bootstrap-turn-start-no-origin"), - threadId: ThreadId.make("thread-bootstrap-no-origin"), - message: { - messageId: MessageId.make("msg-bootstrap-no-origin"), - role: "user", - text: "hello", - attachments: [], + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-no-origin"), + threadId: ThreadId.make("thread-bootstrap-no-origin"), + message: { + messageId: MessageId.make("msg-bootstrap-no-origin"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, }, - modelSelection: defaultModelSelection, - runtimeMode: "full-access", - interactionMode: "default", - bootstrap: { - createThread: { - projectId: defaultProjectId, - title: "Bootstrap Thread", - modelSelection: defaultModelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - createdAt, - }, - prepareWorktree: { - projectCwd: "/tmp/project", - baseBranch: "main", - branch: "t3code/bootstrap-refName", - startFromOrigin: true, - }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + startFromOrigin: true, }, - createdAt, - }), - ), - ); + }, + createdAt, + }), + ), + ); - assert.deepEqual(remoteExists.mock.calls[0]?.[0], { + assert.deepEqual(remoteExists.mock.calls[0]?.[0], { + cwd: "/tmp/project", + remoteName: "origin", + }); + assert.equal(fetchRemote.mock.calls.length, hasOrigin ? 1 : 0); + assert.equal(remoteBranchExists.mock.calls.length, hasOrigin ? 1 : 0); + if (hasOrigin) { + assert.deepEqual(remoteBranchExists.mock.calls[0]?.[0], { cwd: "/tmp/project", remoteName: "origin", - }); - assert.equal(fetchRemote.mock.calls.length, 0); - assert.equal(resolveRemoteTrackingCommit.mock.calls.length, 0); - assert.deepEqual(createWorktree.mock.calls[0]?.[0], { - cwd: "/tmp/project", refName: "main", - newRefName: "t3code/bootstrap-refName", - baseRefName: "main", - path: null, }); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + } + assert.equal(resolveRemoteTrackingCommit.mock.calls.length, 0); + assert.deepEqual(createWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "main", + newRefName: "t3code/bootstrap-refName", + baseRefName: "main", + path: null, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); it.effect("records setup-script failures without aborting bootstrap turn start", () => @@ -9536,9 +9554,12 @@ it.live( const runs = yield* Effect.forEach( providers, - (provider) => - Effect.acquireUseRelease( - makeOrchestrationIntegrationHarness({ provider }), + (provider) => { + // One counter for the orchestration runtime and the HTTP/WS handlers, + // so reactor writes and subscription reads land in the same total. + const sqlCounter = makeSqlStatementCounter(); + return Effect.acquireUseRelease( + makeOrchestrationIntegrationHarness({ provider, tracer: sqlCounter.tracer }), (harness) => Effect.gen(function* () { yield* seedTransferBudgetHistory(harness, provider); @@ -9551,19 +9572,10 @@ it.live( const baseUrl = yield* getHttpServerUrl(); const cookie = yield* getAuthenticatedSessionCookieHeader(); - - const recorder = makeWebSocketTransferRecorder(); const wsUrl = baseUrl.replace(/^http:/, "ws:") + "/ws"; - const protocolLayer = countingWsRpcProtocolLayer({ - url: wsUrl, - cookie, - recorder, - }); return yield* Effect.scoped( Effect.gen(function* () { - const client = yield* makeCountingWsRpcClient; - const threadSnapshot = yield* measureHttpGet({ url: `${baseUrl}/api/orchestration/threads/${TRANSFER_THREAD_ID}`, headers: { cookie }, @@ -9577,29 +9589,78 @@ it.live( decodedThread.thread.messages.length, TRANSFER_HISTORY_TURN_COUNT * 2, ); + const shellSnapshot = yield* measureHttpGet({ + url: `${baseUrl}/api/orchestration/shell`, + headers: { cookie }, + }); + assert.equal(shellSnapshot.status, 200); + const decodedShell = yield* decodeTransferShellSnapshot( + Buffer.from(shellSnapshot.decodedBody).toString("utf8"), + ); + assert.equal(decodedShell.threads.length, 1); + + // Three sockets, the way real installs look: the capped + // thread-only client, a shell-only socket that isolates the + // sidebar cost, and a second device holding both. + const threadClient = yield* openMeasuredWsClient({ url: wsUrl, cookie }); + const shellClient = yield* openMeasuredWsClient({ url: wsUrl, cookie }); + const secondClient = yield* openMeasuredWsClient({ url: wsUrl, cookie }); + assert.include( + threadClient.recorder.negotiatedExtensions(), + "permessage-deflate", + ); - const threadItems = yield* Queue.unbounded(); - yield* client[ORCHESTRATION_WS_METHODS.subscribeThread]({ - threadId: TRANSFER_THREAD_ID, - afterSequence: decodedThread.snapshotSequence, - requestCompletionMarker: true, - }).pipe( - Stream.runForEach((item) => - Queue.offer(threadItems, item).pipe(Effect.asVoid), + const threadItems = yield* subscribeThreadItems( + threadClient, + decodedThread.snapshotSequence, + ); + const shellItems = yield* subscribeShellItems( + shellClient, + decodedShell.snapshotSequence, + ); + const secondThreadItems = yield* subscribeThreadItems( + secondClient, + decodedThread.snapshotSequence, + ); + const secondShellItems = yield* subscribeShellItems( + secondClient, + decodedShell.snapshotSequence, + ); + assert.equal( + yield* awaitSubscriptionSynchronized( + threadItems, + `${provider} thread subscription to synchronize`, ), - Effect.forkScoped, + "replay", ); - const initialThreadItems = yield* collectQueueUntil( - threadItems, - (item) => item.kind === "synchronized", - `${provider} thread subscription to synchronize`, + assert.equal( + yield* awaitSubscriptionSynchronized( + shellItems, + `${provider} shell subscription to synchronize`, + ), + "replay", + ); + assert.equal( + yield* awaitSubscriptionSynchronized( + secondThreadItems, + `${provider} second client thread subscription to synchronize`, + ), + "replay", + ); + assert.equal( + yield* awaitSubscriptionSynchronized( + secondShellItems, + `${provider} second client shell subscription to synchronize`, + ), + "replay", ); - assert.isFalse(initialThreadItems.some((item) => item.kind === "snapshot")); - assert.include(recorder.negotiatedExtensions(), "permessage-deflate"); yield* queueMeasuredTransferTurn(harness, provider); - const turnStartTotals = recorder.totals(); - yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + const turnStartTotals = threadClient.recorder.totals(); + const shellTurnStartTotals = shellClient.recorder.totals(); + const secondTurnStartTotals = secondClient.recorder.totals(); + const turnStartSqlStatements = sqlCounter.count(); + yield* threadClient.client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.turn.start", commandId: CommandId.make(`transfer:${provider}:measured-turn`), threadId: TRANSFER_THREAD_ID, @@ -9615,26 +9676,95 @@ it.live( createdAt: TRANSFER_MEASURED_TURN_CREATED_AT, }); yield* waitForTurnQuiesced(harness, TRANSFER_MEASURED_TURN_INDEX + 1); - const finalThreadSequence = yield* harness.engine + const finalSequences = yield* harness.engine .readEvents(decodedThread.snapshotSequence, 10_000) .pipe( Stream.runFold( - () => decodedThread.snapshotSequence, - (sequence, event) => - event.aggregateId === TRANSFER_THREAD_ID && isThreadDetailEvent(event) - ? Math.max(sequence, event.sequence) - : sequence, + () => ({ + detail: decodedThread.snapshotSequence, + aggregate: decodedShell.snapshotSequence, + }), + (sequences, event) => + event.aggregateId !== TRANSFER_THREAD_ID + ? sequences + : { + detail: isThreadDetailEvent(event) + ? Math.max(sequences.detail, event.sequence) + : sequences.detail, + aggregate: Math.max(sequences.aggregate, event.sequence), + }, ), ); + const finalThreadSequence = finalSequences.detail; assert.isAbove(finalThreadSequence, decodedThread.snapshotSequence); + const reachedFinalThreadEvent = (item: OrchestrationThreadStreamItem) => + item.kind === "event" && item.event.sequence === finalThreadSequence; + // Shell items carry the sequence of the latest coalesced + // event for the thread, so the last one lands at or past + // the final thread event. + const reachedFinalShellEvent = (item: OrchestrationShellStreamItem) => + item.kind === "thread-upserted" && item.sequence >= finalSequences.aggregate; yield* collectQueueUntil( threadItems, - (item) => - item.kind === "event" && item.event.sequence === finalThreadSequence, + reachedFinalThreadEvent, `${provider} thread stream to reach sequence ${finalThreadSequence}`, ); - const measuredTurnWebSocket = transferDelta(turnStartTotals, recorder.totals()); + yield* collectQueueUntil( + secondThreadItems, + reachedFinalThreadEvent, + `${provider} second client thread stream to reach sequence ${finalThreadSequence}`, + ); + yield* collectQueueUntil( + shellItems, + reachedFinalShellEvent, + `${provider} shell stream to reach sequence ${finalSequences.aggregate}`, + ); + yield* collectQueueUntil( + secondShellItems, + reachedFinalShellEvent, + `${provider} second client shell stream to reach sequence ${finalSequences.aggregate}`, + ); + const measuredTurnWebSocket = transferDelta( + turnStartTotals, + threadClient.recorder.totals(), + ); + const measuredTurnShellWebSocket = transferDelta( + shellTurnStartTotals, + shellClient.recorder.totals(), + ); + const measuredTurnSecondClientWebSocket = transferDelta( + secondTurnStartTotals, + secondClient.recorder.totals(), + ); + const measuredTurnSqlStatements = sqlCounter.count() - turnStartSqlStatements; + + // The second device drops and comes back with the cursors it + // held before the turn, one subscription at a time so the + // catch-up bytes stay separable. + yield* secondClient.close; + const reconnectSqlStart = sqlCounter.count(); + const reconnected = yield* openMeasuredWsClient({ url: wsUrl, cookie }); + const reconnectStartTotals = reconnected.recorder.totals(); + const reconnectThreadItems = yield* subscribeThreadItems( + reconnected, + decodedThread.snapshotSequence, + ); + const reconnectThreadMode = yield* awaitSubscriptionSynchronized( + reconnectThreadItems, + `${provider} reconnected thread subscription to synchronize`, + ); + const reconnectThreadTotals = reconnected.recorder.totals(); + const reconnectShellItems = yield* subscribeShellItems( + reconnected, + decodedShell.snapshotSequence, + ); + const reconnectShellMode = yield* awaitSubscriptionSynchronized( + reconnectShellItems, + `${provider} reconnected shell subscription to synchronize`, + ); + const reconnectShellTotals = reconnected.recorder.totals(); + const reconnectSqlStatements = sqlCounter.count() - reconnectSqlStart; const finalThreadSnapshot = yield* harness.snapshotQuery .getThreadDetailSnapshot(TRANSFER_THREAD_ID) @@ -9659,12 +9789,29 @@ it.live( provider, threadSnapshot, measuredTurnWebSocket, + shellSnapshot, + measuredTurnShellWebSocket, + measuredTurnSecondClientWebSocket, + reconnectThread: { + mode: reconnectThreadMode, + ...transferDelta(reconnectStartTotals, reconnectThreadTotals), + }, + reconnectShell: { + mode: reconnectShellMode, + ...transferDelta(reconnectThreadTotals, reconnectShellTotals), + }, + measuredTurnSqlStatements, + reconnectSqlStatements, } satisfies TransferBudgetRun; - }).pipe(Effect.provide(protocolLayer)), + }), ); }), (harness) => harness.dispose, - ).pipe(Effect.provide(NodeHttpServerTestWithWsDeflate)), + ).pipe( + Effect.provideService(Tracer.Tracer, sqlCounter.tracer), + Effect.provide(NodeHttpServerTestWithWsDeflate), + ); + }, { concurrency: 1 }, ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d4f6db397..0c98911c3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -314,7 +314,6 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( Layer.provide(PullRequestProviderRegistry.layer), Layer.provide(SourceControlProviderRegistryLayerLive), Layer.provide(SourceControlRateLimit.layer), - Layer.provide(VcsProcess.layer), ); const GitManagerLayerLive = GitManager.layer.pipe( @@ -727,7 +726,8 @@ export const makeServerLayer = Layer.unwrap( Layer.provideMerge(HttpServerLive), Layer.provide(ApplicationObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), - Layer.provideMerge(VcsProcess.layer), + // PR reads, Git operations, and WebSocket discovery share one process limiter. + Layer.provide(VcsProcess.layer), Layer.provideMerge(PlatformServicesLive), ); }), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index e3f7e482b..fc5b2c9e5 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -74,6 +74,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa Effect.scoped( Effect.gen(function* () { const releaseCounts = yield* Deferred.make(); + const countsStarted = yield* Deferred.make(); yield* ServerRuntimeStartup.launchStartupHeartbeat.pipe( Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { @@ -83,7 +84,8 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => - Deferred.await(releaseCounts).pipe( + Deferred.succeed(countsStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCounts)), Effect.as({ projectCount: 2, threadCount: 3, @@ -104,6 +106,13 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa flush: Effect.void, }), ); + + // The heartbeat is forked, so the caller is already back here while + // getCounts is still parked. Awaiting countsStarted proves the forked + // work really ran; releaseCounts staying incomplete proves the caller + // never waited for it. + yield* Deferred.await(countsStarted); + assert.equal(yield* Deferred.isDone(releaseCounts), false); }), ), ); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index b32f38145..c08e5a829 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -104,6 +104,21 @@ export const clearPersistedServerRuntimeState = (path: string) => ); }); +/** + * Report whether the pid recorded in a persisted runtime state is still + * running. Signal 0 delivers nothing; it only reports whether the pid exists. + * EPERM means it exists but belongs to another user, which still counts as + * alive. + */ +export const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error instanceof Error && "code" in error && error.code === "EPERM"; + } +}; + export const readPersistedServerRuntimeState = (path: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index a5bb9a9e3..f0cb52003 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -332,6 +332,28 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("forwards explicit output limits to the process boundary", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.execute({ + cwd: "/repo", + args: ["repos", "pr", "list"], + maxOutputBytes: 16 * 1024 * 1024, + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: ["repos", "pr", "list"], + cwd: "/repo", + timeoutMs: 30_000, + maxOutputBytes: 16 * 1024 * 1024, + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("preserves VCS causes without copying upstream details into messages", () => Effect.gen(function* () { const cause = new VcsProcessExitError({ diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index 556dc4bf2..f05f4a758 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -218,6 +218,7 @@ export class AzureDevOpsCli extends Context.Service< readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; + readonly maxOutputBytes?: number; }) => Effect.Effect; readonly listPullRequests: (input: { @@ -362,6 +363,7 @@ export const make = Effect.gen(function* () { args: input.args, cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), }) .pipe( Effect.mapError((error) => diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 21db25e79..cacdd1a3c 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -115,20 +115,3 @@ it.effect("creates Azure DevOps PRs through provider-neutral input names", () => }); }), ); - -it.effect("uses Azure CLI repository detection for default branch lookup", () => - Effect.gen(function* () { - let cwdInput: string | null = null; - const provider = yield* makeProvider({ - getDefaultBranch: (input) => { - cwdInput = input.cwd; - return Effect.succeed("main"); - }, - }); - - const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); - - assert.strictEqual(defaultBranch, "main"); - assert.strictEqual(cwdInput, "/repo"); - }), -); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index eeb4c8fbd..52a15547c 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -149,20 +149,3 @@ it.effect("creates Bitbucket PRs through provider-neutral input names", () => }); }), ); - -it.effect("uses Bitbucket API repository detection for default branch lookup", () => - Effect.gen(function* () { - let cwdInput: string | null = null; - const provider = yield* makeProvider({ - getDefaultBranch: (input) => { - cwdInput = input.cwd; - return Effect.succeed("main"); - }, - }); - - const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); - - assert.strictEqual(defaultBranch, "main"); - assert.strictEqual(cwdInput, "/repo"); - }), -); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 964ed3d02..ea79dc87e 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -66,6 +66,7 @@ describe("GitHubCli.layer", () => { headRefName: "feature/pr-threads", state: "OPEN", mergedAt: null, + updatedAt: "2026-08-24T12:34:56Z", isCrossRepository: true, headRepository: { nameWithOwner: "octocat/codething-mvp", @@ -91,6 +92,7 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + updatedAt: "2026-08-24T12:34:56.000Z", isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", @@ -103,7 +105,7 @@ describe("GitHubCli.layer", () => { "view", "#42", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], cwd: "/repo", timeoutMs: 30_000, diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 974574cbd..a9b65b30e 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,6 +1,8 @@ import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -15,6 +17,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import { decodeGitHubPullRequestJson, decodeGitHubPullRequestListJson, + type NormalizedGitHubPullRequestRecord, } from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -202,11 +205,20 @@ export interface GitHubPullRequestSummary { readonly baseRefName: string; readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; + readonly updatedAt?: string; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; readonly headRepositoryOwnerLogin?: string | null; } +function pullRequestSummary(input: NormalizedGitHubPullRequestRecord): GitHubPullRequestSummary { + const { updatedAt, ...summary } = input; + return { + ...summary, + ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}), + }; +} + export interface GitHubRepositoryCloneUrls { readonly nameWithOwner: string; readonly url: string; @@ -373,9 +385,7 @@ export const make = Effect.gen(function* () { ); } - return Effect.succeed( - decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary), - ); + return Effect.succeed(decoded.success.map(pullRequestSummary)); }), ), ), @@ -388,7 +398,7 @@ export const make = Effect.gen(function* () { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -405,9 +415,7 @@ export const make = Effect.gen(function* () { ); } - return Effect.succeed( - (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success), - ); + return Effect.succeed(pullRequestSummary(decoded.success)); }), ), ), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 3dcc8ab82..738b10498 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -1,3 +1,4 @@ +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -29,7 +30,10 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", - updatedAt: Option.none(), + updatedAt: + summary.updatedAt === undefined + ? Option.none() + : Option.some(DateTime.makeUnsafe(summary.updatedAt)), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } : {}), @@ -162,10 +166,18 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => Result.isSuccess(decoded) ? Effect.succeed( - decoded.success.map((item) => ({ - ...toChangeRequest(item), - updatedAt: item.updatedAt, - })), + decoded.success.map((item) => { + const { updatedAt, ...summary } = item; + return { + ...toChangeRequest({ + ...summary, + ...(Option.isSome(updatedAt) + ? { updatedAt: DateTime.formatIso(updatedAt.value) } + : {}), + }), + updatedAt, + }; + }), ) : Effect.fail( new GitHubCli.GitHubChangeRequestListDecodeError({ diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1b46369e2..b38fe3d5c 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -1,4 +1,3 @@ -import * as NodeOS from "node:os"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -20,6 +19,7 @@ import { } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; +import { expandHomePathWith } from "../pathExpansion.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); @@ -77,16 +77,6 @@ function selectRemoteUrl( } } -function expandHomePath(input: string, path: Path.Path): string { - if (input === "~") { - return NodeOS.homedir(); - } - if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(NodeOS.homedir(), input.slice(2)); - } - return input; -} - export const make = Effect.gen(function* () { const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -137,7 +127,7 @@ export const make = Effect.gen(function* () { }); } - return path.resolve(expandHomePath(trimmed, path)); + return path.resolve(expandHomePathWith(trimmed, path)); }, ); diff --git a/apps/server/src/telemetry/Services/AnalyticsService.ts b/apps/server/src/telemetry/Services/AnalyticsService.ts deleted file mode 100644 index 879a1de7c..000000000 --- a/apps/server/src/telemetry/Services/AnalyticsService.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Compatibility shim for the intentionally excluded orchestration harness. -export { AnalyticsService } from "../AnalyticsService.ts"; diff --git a/apps/server/src/textGeneration/TextGenerationPresets.ts b/apps/server/src/textGeneration/TextGenerationPresets.ts index 709557421..0f5d03480 100644 --- a/apps/server/src/textGeneration/TextGenerationPresets.ts +++ b/apps/server/src/textGeneration/TextGenerationPresets.ts @@ -1,4 +1,4 @@ -import type { TextGenerationPolicy, TextGenerationPolicyKind } from "./TextGenerationPolicy.ts"; +import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; export const defaultTextGenerationPolicy: TextGenerationPolicy = { kind: "default", @@ -30,12 +30,3 @@ export const customTextGenerationPolicy = ( inferRepositoryConventions: false, ...overrides, }); - -export const textGenerationPresets: Record< - Exclude, - TextGenerationPolicy -> = { - default: defaultTextGenerationPolicy, - conventional_commits: conventionalCommitsTextGenerationPolicy, - repo_conventions: repositoryConventionsTextGenerationPolicy, -}; diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 190ef317b..f1f39122d 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -146,7 +146,7 @@ describe("buildBranchNamePrompt", () => { }); describe("buildThreadTitlePrompt", () => { - it("includes the user message in the prompt", () => { + it("includes the user message and the title guidance rules", () => { const result = buildThreadTitlePrompt({ message: "Investigate reconnect regressions after session restore", }); @@ -188,6 +188,24 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("67890 bytes"); }); + it.each([ + { mode: "initial", previousTitle: undefined }, + { mode: "regeneration", previousTitle: "Open Projects in Desktop App" }, + ])( + "tells the $mode prompt not to title linked PRs from local git history", + ({ previousTitle }) => { + const result = buildThreadTitlePrompt({ + message: "$takeover https://github.com/pingdotgg/t3code/pull/8588", + ...(previousTitle === undefined ? {} : { previousTitle }), + }); + + expect(result.prompt).toContain( + "Local git history is not evidence of what a linked PR or issue is about.", + ); + expect(result.prompt).toContain('such as "Take Over PR 8588"'); + }, + ); + it("regenerates from recent thread contents and identifies the previous title", () => { const result = buildThreadTitlePrompt({ message: `USER:\nInvestigate reconnect regressions\n\nASSISTANT:\nThe remaining issue is stale session state`, diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 51e2f5040..22a91abcc 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -238,7 +238,9 @@ Editorial rules: - Do not copy and truncate the user's message. - Avoid project names already visible in the UI, quotes, labels, filler, and trailing punctuation. - Use attached images as primary context for UI issues. -- When a URL or attachment is the only source of the subject, use available tools to inspect it. If it cannot be resolved, remain accurate rather than guessing.`; +- When a URL or attachment is the only source of the subject, use available tools to inspect it directly. +- Local git history is not evidence of what a linked PR or issue is about. Never title the thread after branch names, commit messages, or merged commits found in the checkout. +- If a linked PR or issue cannot be read, fall back to the user's stated action plus its number, such as "Take Over PR 8588". This is the one case where a PR or issue number belongs in the title.`; function regenerateThreadTitlePrompt(previousTitle: string): string { return `Regenerate the title for an existing Pylon thread so the user can recognize it weeks later. @@ -265,7 +267,9 @@ Editorial rules: - Do not copy and truncate a thread message. - Avoid project names already visible in the UI, PR numbers, quotes, labels, filler, and trailing punctuation. - Use attached images as primary context for UI issues. -- When a URL or attachment is the only source of the subject, use available tools to inspect it. If it cannot be resolved, remain accurate rather than guessing. +- When a URL or attachment is the only source of the subject, use available tools to inspect it directly. +- Local git history is not evidence of what a linked PR or issue is about. Never title the thread after branch names, commit messages, or merged commits found in the checkout. +- If a linked PR or issue cannot be read, fall back to the user's stated action plus its number, such as "Take Over PR 8588". This is the one case where a PR or issue number belongs in the title. - Return a meaningfully improved title, not a cosmetic paraphrase of the previous title. Examples of the distinction: diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 1f4f5d8b3..663abcca0 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -30,7 +30,7 @@ import { type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; -import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; +import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -204,6 +204,10 @@ export interface GitRemoteExistsInput { remoteName: string; } +export interface GitRemoteBranchExistsInput extends GitRemoteExistsInput { + refName: string; +} + export interface GitResolveRemoteTrackingCommitInput { cwd: string; refName: string; @@ -295,6 +299,9 @@ export class GitVcsDriver extends Context.Service< ) => Effect.Effect; readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect; + readonly remoteBranchExists: ( + input: GitRemoteBranchExistsInput, + ) => Effect.Effect; readonly resolveRemoteTrackingCommit: ( input: GitResolveRemoteTrackingCommitInput, ) => Effect.Effect; @@ -347,17 +354,6 @@ const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () { }; }); -function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { - const parts = input.split("\0"); - if (parts.length === 0) return []; - - if (truncated && parts[parts.length - 1]?.length) { - parts.pop(); - } - - return parts.filter((value) => value.length > 0); -} - function chunkPathsForGitCheckIgnore(relativePaths: ReadonlyArray): string[][] { const chunks: string[][] = []; let chunk: string[] = []; @@ -541,7 +537,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ? Effect.gen(function* () { const freshness = yield* nowFreshness(); return { - paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated), + paths: splitNullSeparatedGitStdoutPaths(result), truncated: result.stdoutTruncated, freshness, }; @@ -639,7 +635,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }); } - for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { + for (const ignoredPath of splitNullSeparatedGitStdoutPaths(result)) { ignoredPaths.add(ignoredPath); } } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 587a3e4ab..c0621f2c9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1665,6 +1665,23 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const driver = yield* GitVcsDriver.GitVcsDriver; yield* driver.fetchRemote({ cwd, remoteName: "origin" }); + assert.equal( + yield* driver.remoteBranchExists({ + cwd, + remoteName: "origin", + refName: initialBranch, + }), + true, + ); + assert.equal( + yield* driver.remoteBranchExists({ + cwd, + remoteName: "origin", + refName: "local-only", + }), + false, + ); + const resolvedBase = yield* driver.resolveRemoteTrackingCommit({ cwd, refName: initialBranch, diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index ef2d00291..f1fb1b7a7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1292,15 +1292,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); - const remoteBranchExists = ( - cwd: string, - remoteName: string, - refName: string, - ): Effect.Effect => + const remoteBranchExists: GitVcsDriver.GitVcsDriver["Service"]["remoteBranchExists"] = (input) => executeGit( "GitVcsDriver.remoteBranchExists", - cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${refName}`], + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.remoteName}/${input.refName}`], { allowNonZeroExit: true, }, @@ -1447,7 +1443,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if ( primaryRemoteName && - (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) + (yield* remoteBranchExists({ + cwd, + remoteName: primaryRemoteName, + refName: normalizedCandidate, + })) ) { return `${primaryRemoteName}/${normalizedCandidate}`; } @@ -1965,9 +1965,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } - const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( - Effect.orElseSucceed(() => false), - ); + const hasRemoteBranch = yield* remoteBranchExists({ + cwd, + remoteName: publishRemoteName, + refName: branch, + }).pipe(Effect.orElseSucceed(() => false)); if (hasRemoteBranch) { return { status: "skipped_up_to_date" as const, @@ -3315,6 +3317,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* resolveDefaultBranchName, fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), remoteExists, + remoteBranchExists, resolveRemoteTrackingCommit, fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), fetchRemoteTrackingBranch: (input) => diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index bd3e5b4cd..91191178c 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -1,10 +1,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import { TestClock } from "effect/testing"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessExitError, @@ -45,6 +49,63 @@ const captureProcessResult = ( ); describe("VcsProcess.run", () => { + it.effect("bounds a synthetic burst of GitHub API processes", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + const starts = yield* Queue.unbounded(); + const active = yield* Ref.make(0); + const peak = yield* Ref.make(0); + const total = yield* Ref.make(0); + const service = yield* VcsProcess.make.pipe( + Effect.provideService( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ + run: () => + Effect.gen(function* () { + const count = yield* Ref.updateAndGet(active, (held) => held + 1); + yield* Ref.update(peak, (held) => Math.max(held, count)); + yield* Ref.update(total, (held) => held + 1); + yield* Queue.offer(starts, count); + yield* Deferred.await(gate); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }).pipe(Effect.ensuring(Ref.update(active, (count) => count - 1))), + }), + ), + ); + + const burst = yield* Effect.all( + Array.from({ length: 32 }, (_, index) => + service.run({ + operation: `synthetic.github.${index}`, + command: "gh", + args: ["api", "user"], + cwd: "/workspace", + }), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + + yield* Effect.all(Array.from({ length: 4 }, () => Queue.take(starts))); + yield* Effect.yieldNow; + expect(yield* Queue.size(starts)).toBe(0); + expect(yield* Ref.get(peak)).toBe(4); + + yield* Deferred.succeed(gate, undefined); + yield* Fiber.join(burst); + expect(yield* Ref.get(total)).toBe(32); + expect(yield* Ref.get(peak)).toBe(4); + }), + ); + it.effect("collects stdout", () => Effect.gen(function* () { const result = yield* run({ diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index ec245fa13..69df83c42 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -2,6 +2,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Match from "effect/Match"; +import * as Semaphore from "effect/Semaphore"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -52,6 +53,8 @@ export class VcsProcess extends Context.Service< const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; +const VCS_PROCESS_CONCURRENCY = 8; +const GITHUB_PROCESS_CONCURRENCY = 4; const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { const normalized = stderr.toLowerCase(); @@ -101,8 +104,10 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai export const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; + const vcsProcesses = yield* Semaphore.make(VCS_PROCESS_CONCURRENCY); + const githubProcesses = yield* Semaphore.make(GITHUB_PROCESS_CONCURRENCY); - const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { + const runUnbounded = Effect.fn("VcsProcess.runUnbounded")(function* (input: VcsProcessInput) { const baseError = { operation: input.operation, command: input.command, @@ -181,6 +186,11 @@ export const make = Effect.gen(function* () { } satisfies VcsProcessOutput; }); + const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { + const bounded = vcsProcesses.withPermits(1)(runUnbounded(input)); + return yield* input.command === "gh" ? githubProcesses.withPermits(1)(bounded) : bounded; + }); + return VcsProcess.of({ run }); }); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 28a30481b..2cf45a1a9 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -1,6 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -23,6 +22,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; +import { expandHomePathWith } from "../pathExpansion.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -103,16 +103,6 @@ export class WorkspaceEntries extends Context.Service< } >()("t3/workspace/WorkspaceEntries") {} -function expandHomePath(input: string, path: Path.Path): string { - if (input === "~") { - return NodeOS.homedir(); - } - if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(NodeOS.homedir(), input.slice(2)); - } - return input; -} - const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* ( input: FilesystemBrowseInput, path: Path.Path, @@ -127,7 +117,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu } if (!isExplicitRelativePath(input.partialPath)) { - return path.resolve(expandHomePath(input.partialPath, path)); + return path.resolve(expandHomePathWith(input.partialPath, path)); } if (!input.cwd) { @@ -135,7 +125,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu partialPath: input.partialPath, }); } - return path.resolve(expandHomePath(input.cwd, path), input.partialPath); + return path.resolve(expandHomePathWith(input.cwd, path), input.partialPath); }); export const make = Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 5acf6677c..d9eb4cdf2 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -6,7 +6,6 @@ * * @module WorkspacePaths */ -import * as NodeOS from "node:os"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -15,6 +14,8 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { expandHomePathWith } from "../pathExpansion.ts"; + export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()( "WorkspaceRootNotExistsError", { @@ -121,16 +122,6 @@ function toPosixRelativePath(input: string): string { return input.replaceAll("\\", "/"); } -function expandHomePath(input: string, path: Path.Path): string { - if (input === "~") { - return NodeOS.homedir(); - } - if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(NodeOS.homedir(), input.slice(2)); - } - return input; -} - export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -161,7 +152,7 @@ export const make = Effect.gen(function* () { const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", )(function* (workspaceRoot, options) { - const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); + const normalizedWorkspaceRoot = path.resolve(expandHomePathWith(workspaceRoot.trim(), path)); let workspaceStat = yield* statWorkspaceRoot( workspaceRoot, normalizedWorkspaceRoot, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index cf907433e..ac99c84c2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -159,7 +159,6 @@ import * as SourceControlProviderRegistry from "./sourceControl/SourceControlPro import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; -import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; @@ -1080,9 +1079,8 @@ const makeWsRpcLayer = ( if (bootstrap?.prepareWorktree) { let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; - // "Start from origin" is a stored default; repos without an - // origin remote fall back to the local base branch instead of - // failing the whole bootstrap on `git fetch origin`. + // "Start from origin" is a stored default; repos without the + // requested remote branch fall back to the local base branch. const startFromOrigin = bootstrap.prepareWorktree.startFromOrigin === true && (yield* gitWorkflow.remoteExists({ @@ -1094,12 +1092,19 @@ const makeWsRpcLayer = ( cwd: bootstrap.prepareWorktree.projectCwd, remoteName: "origin", }); - const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ + const remoteBaseExists = yield* gitWorkflow.remoteBranchExists({ cwd: bootstrap.prepareWorktree.projectCwd, refName: bootstrap.prepareWorktree.baseBranch, - fallbackRemoteName: "origin", + remoteName: "origin", }); - worktreeBaseRef = resolvedRemoteBase.commitSha; + if (remoteBaseExists) { + const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ + cwd: bootstrap.prepareWorktree.projectCwd, + refName: bootstrap.prepareWorktree.baseBranch, + fallbackRemoteName: "origin", + }); + worktreeBaseRef = resolvedRemoteBase.commitSha; + } } const worktree = yield* gitWorkflow.createWorktree({ cwd: bootstrap.prepareWorktree.projectCwd, @@ -2151,6 +2156,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), { "rpc.aggregate": "pull-requests", }), + [WS_METHODS.pullRequestsSummary]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), { + "rpc.aggregate": "pull-requests", + }), [WS_METHODS.pullRequestsDetail]: (input) => observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { "rpc.aggregate": "pull-requests", @@ -2886,7 +2895,6 @@ export const websocketRpcRouteLayer = Layer.unwrap( ), ), ), - Layer.provide(VcsProcess.layer), ), ), ), diff --git a/apps/web/package.json b/apps/web/package.json index 21082d228..282d415ec 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -67,7 +67,6 @@ "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", "compression": "^1.8.1", - "msw": "2.12.11", "tailwindcss": "^4.0.0", "vite": "catalog:", "vite-plus": "catalog:" diff --git a/apps/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js deleted file mode 100644 index 8fa9dca80..000000000 --- a/apps/web/public/mockServiceWorker.js +++ /dev/null @@ -1,349 +0,0 @@ -/* eslint-disable */ -/* tslint:disable */ - -/** - * Mock Service Worker. - * @see https://github.com/mswjs/msw - * - Please do NOT modify this file. - */ - -const PACKAGE_VERSION = '2.12.11' -const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' -const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') -const activeClientIds = new Set() - -addEventListener('install', function () { - self.skipWaiting() -}) - -addEventListener('activate', function (event) { - event.waitUntil(self.clients.claim()) -}) - -addEventListener('message', async function (event) { - const clientId = Reflect.get(event.source || {}, 'id') - - if (!clientId || !self.clients) { - return - } - - const client = await self.clients.get(clientId) - - if (!client) { - return - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - switch (event.data) { - case 'KEEPALIVE_REQUEST': { - sendToClient(client, { - type: 'KEEPALIVE_RESPONSE', - }) - break - } - - case 'INTEGRITY_CHECK_REQUEST': { - sendToClient(client, { - type: 'INTEGRITY_CHECK_RESPONSE', - payload: { - packageVersion: PACKAGE_VERSION, - checksum: INTEGRITY_CHECKSUM, - }, - }) - break - } - - case 'MOCK_ACTIVATE': { - activeClientIds.add(clientId) - - sendToClient(client, { - type: 'MOCKING_ENABLED', - payload: { - client: { - id: client.id, - frameType: client.frameType, - }, - }, - }) - break - } - - case 'CLIENT_CLOSED': { - activeClientIds.delete(clientId) - - const remainingClients = allClients.filter((client) => { - return client.id !== clientId - }) - - // Unregister itself when there are no more clients - if (remainingClients.length === 0) { - self.registration.unregister() - } - - break - } - } -}) - -addEventListener('fetch', function (event) { - const requestInterceptedAt = Date.now() - - // Bypass navigation requests. - if (event.request.mode === 'navigate') { - return - } - - // Opening the DevTools triggers the "only-if-cached" request - // that cannot be handled by the worker. Bypass such requests. - if ( - event.request.cache === 'only-if-cached' && - event.request.mode !== 'same-origin' - ) { - return - } - - // Bypass all requests when there are no active clients. - // Prevents the self-unregistered worked from handling requests - // after it's been terminated (still remains active until the next reload). - if (activeClientIds.size === 0) { - return - } - - const requestId = crypto.randomUUID() - event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) -}) - -/** - * @param {FetchEvent} event - * @param {string} requestId - * @param {number} requestInterceptedAt - */ -async function handleRequest(event, requestId, requestInterceptedAt) { - const client = await resolveMainClient(event) - const requestCloneForEvents = event.request.clone() - const response = await getResponse( - event, - client, - requestId, - requestInterceptedAt, - ) - - // Send back the response clone for the "response:*" life-cycle events. - // Ensure MSW is active and ready to handle the message, otherwise - // this message will pend indefinitely. - if (client && activeClientIds.has(client.id)) { - const serializedRequest = await serializeRequest(requestCloneForEvents) - - // Clone the response so both the client and the library could consume it. - const responseClone = response.clone() - - sendToClient( - client, - { - type: 'RESPONSE', - payload: { - isMockedResponse: IS_MOCKED_RESPONSE in response, - request: { - id: requestId, - ...serializedRequest, - }, - response: { - type: responseClone.type, - status: responseClone.status, - statusText: responseClone.statusText, - headers: Object.fromEntries(responseClone.headers.entries()), - body: responseClone.body, - }, - }, - }, - responseClone.body ? [serializedRequest.body, responseClone.body] : [], - ) - } - - return response -} - -/** - * Resolve the main client for the given event. - * Client that issues a request doesn't necessarily equal the client - * that registered the worker. It's with the latter the worker should - * communicate with during the response resolving phase. - * @param {FetchEvent} event - * @returns {Promise} - */ -async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId) - - if (activeClientIds.has(event.clientId)) { - return client - } - - if (client?.frameType === 'top-level') { - return client - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - return allClients - .filter((client) => { - // Get only those clients that are currently visible. - return client.visibilityState === 'visible' - }) - .find((client) => { - // Find the client ID that's recorded in the - // set of clients that have registered the worker. - return activeClientIds.has(client.id) - }) -} - -/** - * @param {FetchEvent} event - * @param {Client | undefined} client - * @param {string} requestId - * @param {number} requestInterceptedAt - * @returns {Promise} - */ -async function getResponse(event, client, requestId, requestInterceptedAt) { - // Clone the request because it might've been already used - // (i.e. its body has been read and sent to the client). - const requestClone = event.request.clone() - - function passthrough() { - // Cast the request headers to a new Headers instance - // so the headers can be manipulated with. - const headers = new Headers(requestClone.headers) - - // Remove the "accept" header value that marked this request as passthrough. - // This prevents request alteration and also keeps it compliant with the - // user-defined CORS policies. - const acceptHeader = headers.get('accept') - if (acceptHeader) { - const values = acceptHeader.split(',').map((value) => value.trim()) - const filteredValues = values.filter( - (value) => value !== 'msw/passthrough', - ) - - if (filteredValues.length > 0) { - headers.set('accept', filteredValues.join(', ')) - } else { - headers.delete('accept') - } - } - - return fetch(requestClone, { headers }) - } - - // Bypass mocking when the client is not active. - if (!client) { - return passthrough() - } - - // Bypass initial page load requests (i.e. static assets). - // The absence of the immediate/parent client in the map of the active clients - // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet - // and is not ready to handle requests. - if (!activeClientIds.has(client.id)) { - return passthrough() - } - - // Notify the client that a request has been intercepted. - const serializedRequest = await serializeRequest(event.request) - const clientMessage = await sendToClient( - client, - { - type: 'REQUEST', - payload: { - id: requestId, - interceptedAt: requestInterceptedAt, - ...serializedRequest, - }, - }, - [serializedRequest.body], - ) - - switch (clientMessage.type) { - case 'MOCK_RESPONSE': { - return respondWithMock(clientMessage.data) - } - - case 'PASSTHROUGH': { - return passthrough() - } - } - - return passthrough() -} - -/** - * @param {Client} client - * @param {any} message - * @param {Array} transferrables - * @returns {Promise} - */ -function sendToClient(client, message, transferrables = []) { - return new Promise((resolve, reject) => { - const channel = new MessageChannel() - - channel.port1.onmessage = (event) => { - if (event.data && event.data.error) { - return reject(event.data.error) - } - - resolve(event.data) - } - - client.postMessage(message, [ - channel.port2, - ...transferrables.filter(Boolean), - ]) - }) -} - -/** - * @param {Response} response - * @returns {Response} - */ -function respondWithMock(response) { - // Setting response status code to 0 is a no-op. - // However, when responding with a "Response.error()", the produced Response - // instance will have status code set to 0. Since it's not possible to create - // a Response instance with status code 0, handle that use-case separately. - if (response.status === 0) { - return Response.error() - } - - const mockedResponse = new Response(response.body, response) - - Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { - value: true, - enumerable: true, - }) - - return mockedResponse -} - -/** - * @param {Request} request - */ -async function serializeRequest(request) { - return { - url: request.url, - mode: request.mode, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: await request.arrayBuffer(), - keepalive: request.keepalive, - } -} diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index cbce157f9..c2b343240 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -25,7 +25,7 @@ describe("browser target resolver", () => { }); }); - it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => { + it("preserves explicit loopback URL navigation for a remote Tailscale environment", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( @@ -35,13 +35,29 @@ describe("browser target resolver", () => { }), ).toEqual({ requestedUrl: "http://localhost:5173/dashboard?mode=test#results", - resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results", - resolutionKind: "direct-private-network", + resolvedUrl: "http://localhost:5173/dashboard?mode=test#results", + resolutionKind: "direct", environmentId: "environment-1", }); }); - it("preserves URL credentials when mapping localhost onto a remote host", async () => { + it("preserves explicit IPv4 loopback URL navigation for a private network environment", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.50:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://127.0.0.1:5999/", + }), + ).toEqual({ + requestedUrl: "http://127.0.0.1:5999/", + resolvedUrl: "http://127.0.0.1:5999/", + resolutionKind: "direct", + environmentId: "environment-1", + }); + }); + + it("preserves URL credentials on explicit loopback navigation", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( @@ -49,10 +65,10 @@ describe("browser target resolver", () => { kind: "url", url: "http://user:p%40ss@localhost:5173/dashboard", }).resolvedUrl, - ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard"); + ).toBe("http://user:p%40ss@localhost:5173/dashboard"); }); - it("maps credentialed localhost URLs onto private IPv6 hosts", async () => { + it("preserves credentialed loopback URLs for private IPv6 environments", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", }); @@ -62,10 +78,10 @@ describe("browser target resolver", () => { kind: "url", url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", }).resolvedUrl, - ).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results"); + ).toBe("http://user:p%40ss@localhost:5173/dashboard?mode=test#results"); }); - it("maps schemeless localhost navigation onto a remote environment host", async () => { + it("preserves schemeless localhost navigation for a remote environment", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( @@ -73,7 +89,7 @@ describe("browser target resolver", () => { kind: "url", url: "localhost:3000/app", }).resolvedUrl, - ).toBe("http://192.168.1.25:3000/app"); + ).toBe("localhost:3000/app"); }); it("keeps localhost navigation local for a local environment", async () => { @@ -117,12 +133,12 @@ describe("browser target resolver", () => { port: 5173, }), ).toThrow(/authenticated preview gateway/); - expect(() => + expect( resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { kind: "url", url: "http://localhost:5173", }), - ).toThrow(/authenticated preview gateway/); + ).toMatchObject({ resolvedUrl: "http://localhost:5173", resolutionKind: "direct" }); }); it("normalizes schemeless localhost server-picker values", async () => { @@ -136,6 +152,14 @@ describe("browser target resolver", () => { ).toBe("http://localhost:3000/app"); }); + it("maps discovered loopback servers onto a remote environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect( + resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:3000/app"), + ).toBe("http://192.168.1.25:3000/app"); + }); + it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 684247e28..c06c60b5f 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -207,30 +207,6 @@ export function resolveBrowserNavigationTarget( target: BrowserNavigationTarget, ): PreviewUrlResolution { if (target.kind === "url") { - let parsed: URL | null = null; - try { - parsed = new URL(normalizePreviewUrl(target.url)); - } catch { - // Preserve the existing direct-navigation behavior so the preview host - // reports malformed URL errors through its normal navigation path. - } - if (parsed && isLoopbackHost(parsed.hostname)) { - const environmentUrl = readEnvironmentUrl(environmentId); - if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) { - return resolveEnvironmentPortTarget( - environmentId, - { - kind: "environment-port", - port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), - protocol: parsed.protocol === "https:" ? "https" : "http", - path: `${parsed.pathname}${parsed.search}${parsed.hash}`, - }, - environmentUrl, - target.url, - parsed, - ); - } - } return { requestedUrl: target.url, resolvedUrl: target.url, @@ -244,10 +220,20 @@ export function resolveBrowserNavigationTarget( export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { try { const normalizedUrl = normalizePreviewUrl(rawUrl); - return resolveBrowserNavigationTarget(environmentId, { - kind: "url", - url: normalizedUrl, - }).resolvedUrl; + const parsed = new URL(normalizedUrl); + if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; + return resolveEnvironmentPortTarget( + environmentId, + { + kind: "environment-port", + port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), + protocol: parsed.protocol === "https:" ? "https" : "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }, + readEnvironmentUrl(environmentId), + rawUrl, + parsed, + ).resolvedUrl; } catch { return rawUrl; } diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index a3ba76679..3d9e0e82e 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,6 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { + lazy, + Suspense, useEffect, useState, useSyncExternalStore, @@ -17,7 +19,6 @@ import { primaryServerKeybindingsAtom } from "../state/server"; import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; -import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { resolveSidebarStageFocusRingOffsetClass, @@ -43,6 +44,14 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; +// The settings nav (and the Clerk profile surfaces behind it) only renders on +// settings routes; lazy-loading it keeps that subtree out of the startup chunk. +const SettingsSidebarNav = lazy(() => + import("./settings/SettingsSidebarNav").then((module) => ({ + default: module.SettingsSidebarNav, + })), +); + function subscribeToViewportWidth(onChange: () => void): () => void { window.addEventListener("resize", onChange); return () => window.removeEventListener("resize", onChange); @@ -229,7 +238,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {isOnSettings ? ( <> - + + + ) : legacySidebarEnabled ? ( diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 7c62871de..7d7f84f0e 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -404,13 +404,13 @@ describe("orderedListGutterStyle", () => { expect(orderedListGutterStyle(9, undefined)).toBeUndefined(); }); - it("leaves the default gutter alone for two-digit lists", () => { - expect(orderedListGutterStyle(99, undefined)).toBeUndefined(); + it("widens the gutter for two-digit lists", () => { + expect(orderedListGutterStyle(99, undefined)).toEqual({ "--list-gutter": "3ch" }); }); - it("leaves the default gutter alone for a two-digit list that starts above 1", () => { + it("widens the gutter for a two-digit list that starts above 1", () => { // start=50 + 49 items => last marker is "98", still two digits. - expect(orderedListGutterStyle(49, 50)).toBeUndefined(); + expect(orderedListGutterStyle(49, 50)).toEqual({ "--list-gutter": "3ch" }); }); it("widens the gutter once the last marker reaches three digits", () => { @@ -431,7 +431,7 @@ describe("orderedListGutterStyle", () => { it("uses the widest marker and includes a negative start's minus sign", () => { expect(orderedListGutterStyle(1001, -1000)).toEqual({ "--list-gutter": "6ch" }); expect(orderedListGutterStyle(3, -15)).toEqual({ "--list-gutter": "4ch" }); - expect(orderedListGutterStyle(3, -5)).toBeUndefined(); + expect(orderedListGutterStyle(3, -5)).toEqual({ "--list-gutter": "3ch" }); }); it("treats a missing/zero item count as a single item", () => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 12440c1ee..7f0625f14 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -342,13 +342,10 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb } /** - * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits markers up to - * two characters wide. Once a marker reaches three characters (item 100+), - * `list-style-position: outside` paints it wider than that gutter and clips - * the leading character against the item's own overflow. Rather than widening - * the gutter for every list, only lists whose widest marker is 3+ characters - * get a wider `--list-gutter`. The width includes a negative marker's minus - * sign. + * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits one-character + * markers. Wider markers can extend past it and get clipped by a collapsed + * message's overflow. Widen the gutter to fit the widest marker, including a + * negative marker's minus sign. */ export function orderedListGutterStyle( itemCount: number, @@ -358,7 +355,7 @@ export function orderedListGutterStyle( const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; const lastNumber = firstNumber + Math.max(itemCount - 1, 0); const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); - if (markerWidth <= 2) return undefined; + if (markerWidth <= 1) return undefined; return { "--list-gutter": `${markerWidth + 1}ch` }; } diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 3a73b9569..b48c6c59b 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -11,9 +11,11 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, ThreadShell } from "../types"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; +import type { RightPanelSurface } from "../rightPanelStore"; import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + agentControlledBrowserCloseConfirmation, branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLoadingThreadFromShell, @@ -45,6 +47,43 @@ import { shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; +describe("agent browser close confirmation", () => { + const surfaces = [ + { id: "browser:one", kind: "preview", resourceId: "tab-1" }, + { id: "browser:two", kind: "preview", resourceId: "tab-2" }, + { id: "diff", kind: "diff" }, + ] satisfies RightPanelSurface[]; + + it("only warns for browsers under active agent control", () => { + expect( + agentControlledBrowserCloseConfirmation(surfaces, { + "tab-1": { controller: "none" }, + "tab-2": { controller: "human" }, + }), + ).toBeNull(); + + expect( + agentControlledBrowserCloseConfirmation([surfaces[0]!], { + "tab-1": { controller: "agent" }, + }), + ).toBe( + [ + "Close browser while the agent is using it?", + "The agent is actively controlling this browser. Closing it may interrupt the current browser action.", + ].join("\n"), + ); + }); + + it("counts every agent-controlled browser in a bulk close", () => { + expect( + agentControlledBrowserCloseConfirmation(surfaces, { + "tab-1": { controller: "agent" }, + "tab-2": { controller: "agent" }, + }), + ).toContain("Close 2 browsers"); + }); +}); + describe("isVideoPreviewRequestCurrent", () => { it("rejects changed threads and replaced previews", () => { expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 302299f49..2e5f2e010 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -47,6 +47,8 @@ import { import type { DraftThreadEnvMode } from "../composerDraftStore"; import type { ComposerSubmissionIntent } from "../composer-logic"; import type { TimelineEntry } from "../session-logic"; +import type { DesktopPreviewOverlay } from "../previewStateStore"; +import type { RightPanelSurface } from "../rightPanelStore"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -55,6 +57,29 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function agentControlledBrowserCloseConfirmation( + surfaces: readonly RightPanelSurface[], + desktopByTabId: Readonly | undefined>>, +): string | null { + const activeBrowserCount = surfaces.filter( + (surface) => + surface.kind === "preview" && + surface.resourceId !== null && + desktopByTabId[surface.resourceId]?.controller === "agent", + ).length; + if (activeBrowserCount === 0) return null; + if (activeBrowserCount === 1) { + return [ + "Close browser while the agent is using it?", + "The agent is actively controlling this browser. Closing it may interrupt the current browser action.", + ].join("\n"); + } + return [ + `Close ${activeBrowserCount} browsers while the agent is using them?`, + "The agent is actively controlling these browsers. Closing them may interrupt the current browser actions.", + ].join("\n"); +} + export function codexArtifactTemplatePromptToAppend( currentDraft: string, template: CodexArtifactTemplate, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c30f70b85..be2786d1e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -388,6 +388,7 @@ import { } from "./chat/draftHeroTransition"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + agentControlledBrowserCloseConfirmation, branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, @@ -4172,6 +4173,27 @@ function ChatViewContent(props: ChatViewProps) { storeCloseTerminal, ], ); + const closeAfterAgentBrowserConfirmation = useCallback( + (surfaces: readonly RightPanelSurface[], closeSurfaces: () => void) => { + const message = agentControlledBrowserCloseConfirmation( + surfaces, + activePreviewState.desktopByTabId, + ); + if (!message) { + closeSurfaces(); + return; + } + const localApi = readLocalApi(); + if (!localApi) return; + void localApi.dialogs.confirm(message, { variant: "destructive" }).then( + (confirmed) => { + if (confirmed) closeSurfaces(); + }, + () => undefined, + ); + }, + [activePreviewState.desktopByTabId], + ); const syncActivePreviewSurface = useCallback(() => { if (!activeThreadRef) return; const nextActiveSurface = selectActiveRightPanelSurface( @@ -4182,14 +4204,26 @@ function ChatViewContent(props: ChatViewProps) { setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId); } }, [activeThreadRef]); + const finishRightPanelSurfaceClose = useCallback( + (surfaces: readonly RightPanelSurface[]) => { + if (!activeThreadRef) return; + cleanupRightPanelSurfaces(surfaces); + const store = useRightPanelStore.getState(); + for (const surface of surfaces) { + store.closeSurface(activeThreadRef, surface.id); + } + syncActivePreviewSurface(); + }, + [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], + ); const closeRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; - const finishClose = () => { - cleanupRightPanelSurfaces([surface]); - useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); - syncActivePreviewSurface(); - }; + const finishClose = () => finishRightPanelSurfaceClose([surface]); + if (surface.kind === "preview") { + closeAfterAgentBrowserConfirmation([surface], finishClose); + return; + } if (surface.kind !== "terminal") { finishClose(); return; @@ -4209,23 +4243,22 @@ function ChatViewContent(props: ChatViewProps) { [ activeThreadRef, activeTerminalLabelsById, - cleanupRightPanelSurfaces, - syncActivePreviewSurface, + closeAfterAgentBrowserConfirmation, + finishRightPanelSurfaceClose, ], ); const closeOtherRightPanelSurfaces = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id); - cleanupRightPanelSurfaces(surfaces); - useRightPanelStore.getState().closeOtherSurfaces(activeThreadRef, surface.id); - syncActivePreviewSurface(); + const finishClose = () => finishRightPanelSurfaceClose(surfaces); + closeAfterAgentBrowserConfirmation(surfaces, finishClose); }, [ activeThreadRef, - cleanupRightPanelSurfaces, + closeAfterAgentBrowserConfirmation, + finishRightPanelSurfaceClose, rightPanelState.surfaces, - syncActivePreviewSurface, ], ); const closeRightPanelSurfacesToRight = useCallback( @@ -4234,22 +4267,26 @@ function ChatViewContent(props: ChatViewProps) { const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id); if (surfaceIndex < 0) return; const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1); - cleanupRightPanelSurfaces(surfaces); - useRightPanelStore.getState().closeSurfacesToRight(activeThreadRef, surface.id); - syncActivePreviewSurface(); + const finishClose = () => finishRightPanelSurfaceClose(surfaces); + closeAfterAgentBrowserConfirmation(surfaces, finishClose); }, [ activeThreadRef, - cleanupRightPanelSurfaces, + closeAfterAgentBrowserConfirmation, + finishRightPanelSurfaceClose, rightPanelState.surfaces, - syncActivePreviewSurface, ], ); const closeAllRightPanelSurfaces = useCallback(() => { if (!activeThreadRef) return; - cleanupRightPanelSurfaces(rightPanelState.surfaces); - useRightPanelStore.getState().closeAllSurfaces(activeThreadRef); - }, [activeThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces]); + const finishClose = () => finishRightPanelSurfaceClose(rightPanelState.surfaces); + closeAfterAgentBrowserConfirmation(rightPanelState.surfaces, finishClose); + }, [ + activeThreadRef, + closeAfterAgentBrowserConfirmation, + finishRightPanelSurfaceClose, + rightPanelState.surfaces, + ]); const copyRightPanelFilePath = useCallback((relativePath: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( diff --git a/apps/web/src/components/DiffFilePathCopyButton.tsx b/apps/web/src/components/DiffFilePathCopyButton.tsx new file mode 100644 index 000000000..49b00ed89 --- /dev/null +++ b/apps/web/src/components/DiffFilePathCopyButton.tsx @@ -0,0 +1,41 @@ +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useRef } from "react"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { + ANCHORED_COPY_TOAST_TIMEOUT_MS, + showAnchoredCopyErrorToast, + showAnchoredCopySuccessToast, +} from "./ui/anchoredCopyToast"; +import { Button } from "./ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +export function DiffFilePathCopyButton({ filePath }: { filePath: string }) { + const ref = useRef(null); + const { copyToClipboard, isCopied } = useCopyToClipboard({ + onCopy: () => showAnchoredCopySuccessToast(ref), + onError: (error) => showAnchoredCopyErrorToast(ref, error), + timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS, + }); + + return ( + + copyToClipboard(filePath, undefined)} + /> + } + > + {isCopied ? : } + + +

{isCopied ? "Copied" : "Copy path"}

+
+
+ ); +} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 66886c3d4..acdc54bb2 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -30,13 +30,15 @@ import { cn } from "~/lib/utils"; import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; import { useTheme } from "../hooks/useTheme"; import { - buildFileDiffRenderKey, + buildFileDiffContentVersion, + buildFileDiffIdentityKey, getDiffCollapseIconClassName, getDiffLineStat, getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, } from "../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; @@ -44,6 +46,7 @@ import { useProject, useThread } from "../state/entities"; import { resolveThreadRouteRef } from "../threadRoutes"; import { useClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; +import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { DiffStatLabel } from "./chat/DiffStatLabel"; import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; @@ -299,7 +302,7 @@ export default function DiffPanel({ const selectedGitSource = branchDiffPreview.data?.sources.find( (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), ); - const loadDiffFiles = useMemo(() => { + const currentLoadDiffFiles = useMemo(() => { const preview = branchDiffPreview.data; if (selectedTurnId !== null || !activeThread || !preview || !selectedGitSource) { return undefined; @@ -320,6 +323,13 @@ export default function DiffPanel({ selectedGitSource, selectedTurnId, ]); + const loadDiffFilesRef = useRef(currentLoadDiffFiles); + loadDiffFilesRef.current = currentLoadDiffFiles; + const loadDiffFiles = useCallback(async (fileDiff) => { + const loader = loadDiffFilesRef.current; + if (!loader) throw new Error("Diff file contents are unavailable for this selection."); + return loader(fileDiff); + }, []); const localBranchRefs = useEnvironmentQuery( selectedTurnId === null && selectedGitScope === "branch" && @@ -400,17 +410,19 @@ export default function DiffPanel({ () => renderableFiles.map((fileDiff) => ({ fileDiff, - fileKey: buildFileDiffRenderKey(fileDiff), + fileKey: buildFileDiffIdentityKey(fileDiff), + fileVersion: buildFileDiffContentVersion(fileDiff), })), [renderableFiles], ); const codeViewFiles = useMemo( () => - renderableFileEntries.map(({ fileDiff, fileKey }) => { + renderableFileEntries.map(({ fileDiff, fileKey, fileVersion }) => { return { fileDiff, filePath: resolveFileDiffPath(fileDiff), fileKey, + fileVersion, collapsed: collapsedDiffFileKeys.has(fileKey), }; }), @@ -909,6 +921,9 @@ export default function DiffPanel({ sectionId={reviewSectionId} sectionTitle={reviewSectionTitle} composerDraftTarget={composerDraftTarget} + renderHeaderFilenameSuffix={(fileDiff) => ( + + )} renderHeaderPrefix={(fileDiff, fileKey, collapsed) => { const filePath = resolveFileDiffPath(fileDiff); return ( @@ -948,9 +963,10 @@ export default function DiffPanel({ lineDiffType: "none", overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), + preferredHighlighter: PREFERRED_HIGHLIGHTER, themeType: resolvedTheme as DiffThemeType, stickyHeaders: true, - ...(loadDiffFiles ? { loadDiffFiles } : {}), + ...(currentLoadDiffFiles ? { loadDiffFiles } : {}), }} /> diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 3ec748c6b..bcf90b118 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -4,6 +4,7 @@ import * as Schema from "effect/Schema"; import { useEffect, useMemo, type ReactNode } from "react"; import { useTheme } from "../hooks/useTheme"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; export class DiffWorkerError extends Schema.TaggedErrorClass()("DiffWorkerError", { operation: Schema.Literals(["create-worker", "get-render-options", "set-render-options"]), @@ -73,6 +74,7 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { }} highlighterOptions={{ theme: diffThemeName, + preferredHighlighter: PREFERRED_HIGHLIGHTER, tokenizeMaxLineLength: 1_000, useTokenTransformer: true, }} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index d448a720e..1c75476b9 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -17,7 +17,15 @@ import type { } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; import * as Option from "effect/Option"; -import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { + type MouseEvent, + useCallback, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, +} from "react"; import { flushSync } from "react-dom"; import { CheckIcon, @@ -91,7 +99,7 @@ import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { readLocalApi } from "~/localApi"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; -import { openPullRequestLink } from "~/lib/openPullRequestLink"; +import { openPullRequestLink, useOpenPrLink } from "~/lib/openPullRequestLink"; interface GitActionsControlProps { gitCwd: string | null; @@ -995,6 +1003,7 @@ export default function GitActionsControl({ () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), [activeThreadRef], ); + const openPrLink = useOpenPrLink(activeThreadRef ?? undefined); const activeDraftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) @@ -1442,7 +1451,7 @@ export default function GitActionsControl({ const toastCta = actionResult.toast.cta; let toastActionProps: { children: string; - onClick: () => void; + onClick: (event: MouseEvent) => void; } | null = null; if (toastCta.kind === "run_action") { toastActionProps = { @@ -1457,11 +1466,9 @@ export default function GitActionsControl({ } else if (toastCta.kind === "open_pr") { toastActionProps = { children: toastCta.label, - onClick: () => { - const api = readLocalApi(); - if (!api) return; + onClick: (event) => { closeResultToast(); - void api.shell.openExternal(toastCta.url); + openPrLink(event, toastCta.url); }, }; } diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 52c42fbd6..f6b06e170 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -185,6 +185,8 @@ import { orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, + useRetainedValue, + useSidebarRowSubscriptionLease, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; @@ -376,6 +378,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); + const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const runningTerminalIds = useThreadRunningTerminalIds({ @@ -417,7 +420,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const threadProjectCwd = threadProject?.workspaceRoot ?? null; const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; const gitStatus = useEnvironmentQuery( - thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null + leaseLiveStatus && thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, @@ -459,16 +462,27 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }, }); const linkedPullRequestStatus = useLinkedThreadPullRequest( - thread.environmentId, - thread.linkedPullRequest, + leaseLiveStatus ? thread.environmentId : null, + leaseLiveStatus ? thread.linkedPullRequest : null, + ); + const visibleGitStatus = useRetainedValue( + JSON.stringify([thread.environmentId, gitCwd]), + gitStatus.data, + ); + const visibleLinkedPullRequestStatus = useRetainedValue( + thread.linkedPullRequest === null + ? null + : JSON.stringify([thread.environmentId, thread.linkedPullRequest]), + linkedPullRequestStatus, ); const pr = thread.linkedPullRequest == null - ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) - : (linkedPullRequestStatus?.pr ?? null); + ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: visibleGitStatus }) + : (visibleLinkedPullRequestStatus?.pr ?? null); const prStatus = prStatusIndicator( pr, - linkedPullRequestStatus?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider, + visibleLinkedPullRequestStatus?.sourceControlProvider ?? + visibleGitStatus?.sourceControlProvider, ); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; @@ -681,6 +695,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr return ( { + async (member: SidebarProjectGroupMember) => { const memberProjectRef = scopeProjectRef(member.environmentId, member.id); const result = await deleteProject({ environmentId: member.environmentId, input: { projectId: member.id, - ...(options.force === true ? { force: true } : {}), + force: true, }, }); if (result._tag === "Failure") { @@ -1534,7 +1549,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), - "This permanently clears conversation history for those threads.", + "This permanently clears conversation history for those threads and any archived threads.", "This removes only this project entry.", "This action cannot be undone.", ].join("\n") @@ -1544,6 +1559,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), + "This permanently clears any archived conversation history.", "This removes only this project entry.", ].join("\n"), { variant: "destructive" }, @@ -1552,7 +1568,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - const result = await removeProject(member, { force: true }); + const result = await removeProject(member); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -1593,6 +1609,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec `Remove project "${member.title}"?`, `Path: ${member.workspaceRoot}`, ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), + "This permanently clears any archived conversation history.", "This removes only this project entry.", ].join("\n"); const confirmed = await api.dialogs.confirm(message, { variant: "destructive" }); diff --git a/apps/web/src/components/ProjectScriptsControl.test.tsx b/apps/web/src/components/ProjectScriptsControl.test.tsx deleted file mode 100644 index d9f3e7e69..000000000 --- a/apps/web/src/components/ProjectScriptsControl.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { ProjectScript, ResolvedKeybindingsConfig } from "@t3tools/contracts"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import ProjectScriptsControl from "./ProjectScriptsControl"; - -const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; -const PRIMARY_SCRIPT: ProjectScript = { - id: "dev", - name: "Dev", - command: "vp dev", - icon: "play", - runOnWorktreeCreate: false, -}; - -function renderControl(scripts: ReadonlyArray) { - return renderToStaticMarkup( - {}} - onAddScript={async () => undefined as never} - onUpdateScript={async () => undefined as never} - onDeleteScript={async () => undefined as never} - />, - ); -} - -function buttonTag(html: string, ariaLabel: string) { - return html.match(new RegExp(`]*aria-label="${ariaLabel}"[^>]*>`))?.[0]; -} - -function expectResponsiveXsControl(markup: string | undefined) { - expect(markup).toBeDefined(); - expect(markup).toContain("h-7"); - expect(markup).toContain("gap-1"); - expect(markup).toContain("text-sm"); - expect(markup).toContain("sm:h-6"); - expect(markup).toContain("sm:text-xs"); - expect(markup).toContain("w-7"); - expect(markup).toContain("px-0"); - expect(markup).toContain("sm:w-6"); - expect(markup).toContain("@3xl/header-actions:w-auto!"); - expect(markup).toContain("@3xl/header-actions:px-[calc(--spacing(2)-1px)]"); -} - -describe("ProjectScriptsControl compact controls", () => { - it("keeps the primary Run control compact and expands it with its label", () => { - const html = renderControl([PRIMARY_SCRIPT]); - - expectResponsiveXsControl(buttonTag(html, "Run Dev")); - expect(html).toContain( - 'class="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"', - ); - }); - - it("keeps the standalone Add control compact and expands it with its label", () => { - const html = renderControl([]); - - expectResponsiveXsControl(buttonTag(html, "Add action")); - expect(html).toContain( - 'class="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"', - ); - }); -}); diff --git a/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx b/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx index 28242b88f..56aaa9e96 100644 --- a/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx +++ b/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx @@ -133,7 +133,7 @@ function EnvironmentUpdateRow({ break; default: trailing = ( - ); diff --git a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx index 639f07c38..a0ed3d804 100644 --- a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx +++ b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx @@ -281,7 +281,7 @@ export function ProviderUpdatePrimaryNotification() { children: "Settings", onClick: openSettings, }, - actionVariant: oneClickProviders.length > 0 ? "default" : "outline", + actionVariant: "outline", data: { leadingIcon: updateProviders.length === 1 ? ( diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index eaab3d70b..22a71b7dd 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -153,7 +153,7 @@ export function ServerUpdateAction({ } return ( - ); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4e742fbba..7651eca88 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -23,6 +23,57 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; // so this limit is a direct renderer-heap and server-load multiplier — keep // it small; cold opens still render instantly from the cached snapshot. export const SIDEBAR_THREAD_PREWARM_LIMIT = 3; +// A small buffer keeps the next few rows warm without leasing every row that +// content-visibility leaves mounted below the scroll viewport. +export const SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX = 160; + +export function useSidebarRowSubscriptionLease(isActive: boolean): { + readonly leaseLiveStatus: boolean; + readonly rowRef: React.Dispatch>; +} { + const [row, setRow] = React.useState(null); + const [isNearViewport, setIsNearViewport] = React.useState(isActive); + + React.useEffect(() => { + if (isActive) { + setIsNearViewport(true); + return; + } + if (row === null) return; + if (typeof IntersectionObserver === "undefined") { + setIsNearViewport(true); + return; + } + + const scrollRoot = row.closest('[data-slot="scroll-area-viewport"]'); + const observer = new IntersectionObserver( + ([entry]) => setIsNearViewport(entry?.isIntersecting === true), + { + root: scrollRoot, + rootMargin: `${SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX}px 0px`, + }, + ); + observer.observe(row); + return () => observer.disconnect(); + }, [isActive, row]); + + return { + leaseLiveStatus: isActive || isNearViewport, + rowRef: setRow, + }; +} + +// A row keeps the last live value it rendered so a released lease never +// blanks its badge. The value is bound to `key`, so a different worktree or +// linked pull request cannot reuse the previous one. +export function useRetainedValue(key: string | null, value: T | null): T | null { + const retained = React.useRef<{ readonly key: string; readonly value: T } | null>(null); + if (key !== null && value !== null) { + retained.current = { key, value }; + } + if (value !== null) return value; + return key !== null && retained.current?.key === key ? retained.current.value : null; +} // The list already reaches its destination through sortable transforms while // the pointer is down. dnd-kit's default also animates the committed DOM order diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1d9a349eb..d576dab83 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -145,6 +145,8 @@ import { sortPinnedThreadsForSidebar, sortSettledThreadsForSidebar, sortThreadsForSidebar, + useRetainedValue, + useSidebarRowSubscriptionLease, useThreadJumpHintVisibility, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; @@ -206,9 +208,9 @@ import { // stays behind an explicit Show more. const SETTLED_TAIL_INITIAL_COUNT = 10; const SETTLED_TAIL_PAGE_COUNT = 25; -// Keep the v2 key so existing preferences survive the v2-to-default rename. -const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; -const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; +// Fresh keys deliberately reset both shelves to collapsed for existing users. +const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; +const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -789,6 +791,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { [thread.environmentId, thread.id], ); const threadKey = scopedThreadKey(threadRef); + const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive); const isRegeneratingTitle = thread.titleRegeneration != null; const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); @@ -802,21 +805,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const gitCwd = thread.worktreePath ?? props.projectCwd; const linkedPullRequestStatus = useLinkedThreadPullRequest( - thread.environmentId, - thread.linkedPullRequest, + leaseLiveStatus ? thread.environmentId : null, + leaseLiveStatus ? thread.linkedPullRequest : null, ); const gitStatus = useEnvironmentQuery( - (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null + leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, }) : null, ); + const visibleGitStatus = useRetainedValue( + JSON.stringify([thread.environmentId, gitCwd]), + gitStatus.data, + ); const retainTerminalOnBranchMismatch = thread.worktreePath === null; const pr = resolveDisplayedThreadPr({ threadBranch: thread.branch, - gitStatus: gitStatus.data, + gitStatus: visibleGitStatus, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, linkedPullRequest: thread.linkedPullRequest, @@ -895,11 +902,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", activeWorktreePath: thread.worktreePath, activeThreadBranch: thread.branch, - currentGitBranch: gitStatus.data?.refName ?? null, + currentGitBranch: visibleGitStatus?.refName ?? null, }); const prProvider = resolveDisplayedThreadPrProvider({ threadBranch: thread.branch, - gitStatus: gitStatus.data, + gitStatus: visibleGitStatus, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, linkedPullRequest: thread.linkedPullRequest, @@ -910,7 +917,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { useEffect(() => { const nextSnapshot = nextThreadChangeRequestSnapshot({ threadBranch: thread.branch, - gitStatus: gitStatus.data, + gitStatus: visibleGitStatus, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, linkedPullRequest: thread.linkedPullRequest, @@ -920,7 +927,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onChangeRequestSnapshot(threadKey, nextSnapshot); }, [ changeRequestSnapshot, - gitStatus.data, + visibleGitStatus, linkedPullRequestStatus, onChangeRequestSnapshot, retainTerminalOnBranchMismatch, @@ -1237,6 +1244,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { void; }) { const { thread } = props; + const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease( + props.isHighlighted || props.isRouteActive, + ); // Same details tooltip as the regular rows: a search hit is still a thread, // and the hover card is how you disambiguate identically-titled results. const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( - (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null + leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, }) : null, ); + const visibleGitStatus = useRetainedValue( + JSON.stringify([thread.environmentId, gitCwd]), + gitStatus.data, + ); const branchMismatch = resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", activeWorktreePath: thread.worktreePath, activeThreadBranch: thread.branch, - currentGitBranch: gitStatus.data?.refName ?? null, + currentGitBranch: visibleGitStatus?.refName ?? null, }); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -1667,6 +1683,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { -
- Pylon -
- - ); -} diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index f81565718..874e338e9 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -11,7 +11,7 @@ export type ComposerBannerVariant = "default" | "error" | "info" | "success" | " const surfaceColors = cn( "[--chat-composer-attached-surface:var(--chat-composer-glass-surface,var(--card))]", - "dark:[--chat-composer-attached-surface:var(--chat-composer-glass-surface,color-mix(in_srgb,var(--background)_96%,var(--color-white)))]", + "dark:[--chat-composer-attached-surface:var(--chat-composer-glass-surface,var(--surface-raised))]", "[html[data-theme-id]_&]:[--chat-composer-attached-surface:var(--app-theme-surface-raised)]", ); @@ -27,9 +27,8 @@ const variantColors: Record = { default: neutralOutline, error: "[--chat-composer-attached-outline:color-mix(in_srgb,var(--error)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--error)_8%,transparent)]", - info: "[--chat-composer-attached-outline:color-mix(in_srgb,var(--info)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--info)_4%,transparent)]", - success: - "[--chat-composer-attached-outline:color-mix(in_srgb,var(--success)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--success)_4%,transparent)]", + info: neutralOutline, + success: neutralOutline, warning: "[--chat-composer-attached-outline:color-mix(in_srgb,var(--warning)_28%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--warning)_8%,transparent)]", }; @@ -59,7 +58,7 @@ function Surface({ "before:bg-[color-mix(in_srgb,var(--chat-composer-attached-surface)_var(--glass-opacity),transparent)] before:bg-[linear-gradient(var(--chat-composer-attached-tint),var(--chat-composer-attached-tint))] before:backdrop-blur-(--glass-blur) before:backdrop-saturate-(--glass-saturation)", "before:mask-[linear-gradient(to_top,transparent_0_var(--chat-composer-attachment-overlap),black_var(--chat-composer-attachment-overlap))] before:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]", "dark:supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-[linear-gradient(var(--chat-composer-attached-tint),var(--chat-composer-attached-tint)),linear-gradient(to_top,transparent_0_var(--chat-composer-attachment-overlap),rgb(0_0_0/18%)_var(--chat-composer-attachment-overlap),transparent_calc(var(--chat-composer-attachment-overlap)+10px))]", - "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-(--chat-composer-attached-surface)", + "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:before:bg-(--chat-composer-attached-surface)", className, )} {...props} @@ -71,8 +70,8 @@ function Surface({ const peekBorder: Record = { default: "border-(--chat-composer-attached-outline)", error: "border-destructive/24", - info: "border-info/24", - success: "border-success/24", + info: "border-(--chat-composer-attached-outline)", + success: "border-(--chat-composer-attached-outline)", warning: "border-warning/24", }; @@ -90,7 +89,7 @@ function Peek({ neutralOutline, "absolute inset-x-0 bottom-0 z-0 mx-auto h-3 w-[96%] cursor-pointer rounded-t-2xl border border-b-0 shadow-[0_6px_18px_rgb(0_0_0/6%)]", "bg-[color-mix(in_srgb,var(--chat-composer-attached-surface)_var(--glass-opacity),transparent)] backdrop-blur-(--glass-blur) backdrop-saturate-(--glass-saturation)", - "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:bg-(--chat-composer-attached-surface)", + "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:bg-(--chat-composer-attached-surface)", "transition-opacity duration-150 ease-out focus-visible:outline-2 focus-visible:outline-ring", peekBorder[variant], className, diff --git a/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx b/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx deleted file mode 100644 index 3c610a7b5..000000000 --- a/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { ThreadId } from "@t3tools/contracts"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { ComposerPendingTerminalContextChip } from "./ComposerPendingTerminalContexts"; - -describe("ComposerPendingTerminalContextChip", () => { - it("renders expired terminal contexts with error styling", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('data-terminal-context-expired="true"'); - expect(markup).toContain("border-destructive/35"); - expect(markup).toContain("Terminal 1 lines 2-4"); - }); -}); diff --git a/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx b/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx deleted file mode 100644 index 3ffb4fa9c..000000000 --- a/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { getComposerPromptLengthValidationMessage } from "./composerSubmission"; -import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; - -describe("ComposerPromptLengthValidation", () => { - it("renders oversized prompt feedback as an actionable composer alert", () => { - const message = getComposerPromptLengthValidationMessage( - "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1), - ); - - const markup = renderToStaticMarkup(); - - expect(markup).toContain('role="alert"'); - expect(markup).toContain('data-chat-composer-validation="prompt-length"'); - expect(markup).toContain( - "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.", - ); - expect(markup).not.toContain("ProviderValidationError"); - }); -}); diff --git a/apps/web/src/components/chat/ComposerSurface.tsx b/apps/web/src/components/chat/ComposerSurface.tsx index b4bad7740..cc6b11a9d 100644 --- a/apps/web/src/components/chat/ComposerSurface.tsx +++ b/apps/web/src/components/chat/ComposerSurface.tsx @@ -15,12 +15,12 @@ function Shell({ className={cn( "group/composer-surface relative isolate mx-auto w-full max-w-3xl", "[--chat-composer-drawer-inset:1.375rem] [--chat-composer-glass-surface:var(--card)] [--chat-composer-outline:rgb(0_0_0/8%)]", - "dark:[--chat-composer-glass-surface:color-mix(in_srgb,var(--background)_96%,var(--color-white))] dark:[--chat-composer-highlight:rgb(255_255_255/3%)] dark:[--chat-composer-outline:color-mix(in_srgb,var(--color-white)_5%,transparent)]", + "dark:[--chat-composer-glass-surface:var(--surface-raised)] dark:[--chat-composer-highlight:rgb(255_255_255/3%)] dark:[--chat-composer-outline:color-mix(in_srgb,var(--color-white)_5%,transparent)]", "[html[data-theme-id]_&]:[--chat-composer-glass-surface:var(--app-theme-surface-raised)] [html[data-theme-id]_&]:[--chat-composer-outline:var(--app-theme-toolbar-border)]", "dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-highlight:color-mix(in_srgb,var(--app-theme-input)_12%,transparent)] dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-outline:color-mix(in_srgb,var(--app-theme-input)_30%,var(--background))]", "dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-highlight:color-mix(in_srgb,#432d48_12%,transparent)] dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-outline:#241e28]", "before:pointer-events-none before:absolute before:inset-0 before:z-0 before:rounded-[22px] before:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] before:backdrop-blur-(--glass-blur) before:backdrop-saturate-(--glass-saturation)", - "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-(--chat-composer-glass-surface)", + "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:before:bg-(--chat-composer-glass-surface)", "has-data-[composer-banner-surface=attached]:before:hidden", contextStrip && [ "[--chat-composer-context-extension:2.25rem] sm:[--chat-composer-context-extension:2rem]", @@ -70,7 +70,7 @@ function Main({ className, ...props }: ComponentProps<"div">) { "after:z-20 after:hidden group-has-data-[composer-banner-surface=attached]/composer-surface:after:block", "group-has-data-[composer-banner-surface=attached]/composer-surface:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] group-has-data-[composer-banner-surface=attached]/composer-surface:backdrop-blur-(--glass-blur) group-has-data-[composer-banner-surface=attached]/composer-surface:backdrop-saturate-(--glass-saturation)", "group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] dark:group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-none", - "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:group-has-data-[composer-banner-surface=attached]/composer-surface:bg-(--chat-composer-glass-surface)", + "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:group-has-data-[composer-banner-surface=attached]/composer-surface:bg-(--chat-composer-glass-surface)", "group-has-data-[composer-banner-surface=attached]/composer-surface:**:data-[chat-composer-mobile-collapsed=true]:min-h-[calc(1rem+1px)]", className, )} @@ -86,7 +86,7 @@ function ContextStrip({ className, ...props }: ComponentProps<"div">) { className={cn( "group/composer-context relative isolate mx-auto -mt-4 flex w-[calc(100%-2*var(--chat-composer-drawer-inset))] items-center gap-2 overflow-x-clip overflow-y-visible ps-1 pe-2 pt-5 pb-1", "before:absolute before:inset-0 before:-z-1 before:rounded-b-[16px] before:border before:border-(--chat-composer-outline) before:mask-[linear-gradient(to_bottom,transparent_0_1rem,black_1rem)] before:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)]", - "dark:before:border-white/7 dark:before:bg-[linear-gradient(to_bottom,transparent_0_1rem,rgb(0_0_0/18%)_1rem,transparent_calc(1rem+10px)),linear-gradient(rgb(255_255_255/2%),rgb(255_255_255/2%))] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]", + "dark:before:border-white/7 dark:before:bg-[linear-gradient(to_bottom,transparent_0_1rem,rgb(0_0_0/18%)_1rem,transparent_calc(1rem+10px)),linear-gradient(rgb(255_255_255/1%),rgb(255_255_255/1%))] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]", "group-has-data-[composer-banner-surface=attached]/composer-surface:before:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] group-has-data-[composer-banner-surface=attached]/composer-surface:before:backdrop-blur-(--glass-blur) group-has-data-[composer-banner-surface=attached]/composer-surface:before:backdrop-saturate-(--glass-saturation)", "not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:backdrop-blur-(--glass-blur) not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:backdrop-saturate-(--glass-saturation)", className, diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index e6a6a491b..86e1a5d3c 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -3,41 +3,13 @@ import { CopyIcon, CheckIcon } from "lucide-react"; import { Button } from "../ui/button"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; -import { anchoredToastManager } from "../ui/toast"; +import { + ANCHORED_COPY_TOAST_TIMEOUT_MS, + showAnchoredCopyErrorToast, + showAnchoredCopySuccessToast, +} from "../ui/anchoredCopyToast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -const ANCHORED_TOAST_TIMEOUT_MS = 1000; -const onCopy = (ref: React.RefObject) => { - if (ref.current) { - anchoredToastManager.add({ - data: { - tooltipStyle: true, - }, - positionerProps: { - anchor: ref.current, - }, - timeout: ANCHORED_TOAST_TIMEOUT_MS, - title: "Copied!", - }); - } -}; - -const onCopyError = (ref: React.RefObject, error: Error) => { - if (ref.current) { - anchoredToastManager.add({ - data: { - tooltipStyle: true, - }, - positionerProps: { - anchor: ref.current, - }, - timeout: ANCHORED_TOAST_TIMEOUT_MS, - title: "Failed to copy", - description: error.message, - }); - } -}; - export const MessageCopyButton = memo(function MessageCopyButton({ text, size = "xs", @@ -51,9 +23,9 @@ export const MessageCopyButton = memo(function MessageCopyButton({ }) { const ref = useRef(null); const { copyToClipboard, isCopied } = useCopyToClipboard({ - onCopy: () => onCopy(ref), - onError: (error: Error) => onCopyError(ref, error), - timeout: ANCHORED_TOAST_TIMEOUT_MS, + onCopy: () => showAnchoredCopySuccessToast(ref), + onError: (error: Error) => showAnchoredCopyErrorToast(ref, error), + timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS, }); return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b1ecdc067..ca01c75e6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1297,7 +1297,9 @@ describe("deriveMessagesTimelineRows", () => { }); it("reuses one activity row for initial thinking and the latest tool", () => { - const deriveRows = (toolLifecycleStatus: "inProgress" | "completed" | "declined" | null) => + const deriveRows = ( + toolLifecycleStatus: "inProgress" | "completed" | "failed" | "declined" | null, + ) => deriveMessagesTimelineRows({ timelineEntries: toolLifecycleStatus === null @@ -1316,6 +1318,7 @@ describe("deriveMessagesTimelineRows", () => { requestKind: "command", tone: "tool" as const, toolLifecycleStatus, + ...(toolLifecycleStatus === "inProgress" ? { detail: "exit code 1" } : {}), }, }, ], @@ -1334,6 +1337,7 @@ describe("deriveMessagesTimelineRows", () => { const initialRows = deriveRows(null); const runningRows = deriveRows("inProgress"); const completedRows = deriveRows("completed"); + const failedRows = deriveRows("failed"); const declinedRows = deriveRows("declined"); const initialActivityRow = initialRows.find((row) => row.id === "live-activity-row"); const runningActivityRow = runningRows.find((row) => row.id === "live-activity-row"); @@ -1342,11 +1346,14 @@ describe("deriveMessagesTimelineRows", () => { expect(initialActivityRow).toMatchObject({ kind: "thinking" }); expect(runningActivityRow).toMatchObject({ kind: "work-live", active: true }); expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true }); + expect(failedRows.some((row) => row.kind === "work-live")).toBe(false); + expect(failedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); expect(runningRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); expect(completedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(failedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); expect(declinedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 13ea4b025..6c0114186 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -762,13 +762,18 @@ export function deriveMessagesTimelineRows(input: { const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => workEntryIsActiveTurnActivity(entry.entry), ); + const latestToolFailed = + latestRunningToolEntry === undefined && + latestVisibleToolEntry !== undefined && + latestVisibleToolEntry.entry.toolLifecycleStatus !== "declined" && + workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry); const latestToolKeepsActivityLive = latestRunningToolEntry !== undefined || (latestVisibleToolEntry !== undefined && workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry)); const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = - activeWorkAnchor && latestVisibleToolEntry + activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed ? (() => { const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); return { @@ -786,7 +791,7 @@ export function deriveMessagesTimelineRows(input: { })() : null; const activeWorkEntryIds = new Set( - activeWorkRow === null ? [] : activeToolEntries.map((entry) => entry.id), + activeWorkRow !== null || latestToolFailed ? activeToolEntries.map((entry) => entry.id) : [], ); const appendWorkingRow = () => { nextRows.push({ @@ -1011,7 +1016,7 @@ export function deriveMessagesTimelineRows(input: { if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } - if (input.isWorking && !hasActivityRow) { + if (input.isWorking && (!hasActivityRow || latestToolFailed)) { nextRows.push({ kind: "thinking", id: LIVE_ACTIVITY_ROW_ID, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index cdba98c50..4731c77df 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -62,6 +62,7 @@ import { resolveDiffThemeName, resolveFileDiffPath, } from "../../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; import { BotIcon, @@ -2124,6 +2125,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte collapsed: false, diffStyle: "unified", theme: resolveDiffThemeName(ctx.resolvedTheme), + preferredHighlighter: PREFERRED_HIGHLIGHTER, }} /> ))} diff --git a/apps/web/src/components/chat/PanelLayoutControls.test.tsx b/apps/web/src/components/chat/PanelLayoutControls.test.tsx deleted file mode 100644 index 51ae1a73a..000000000 --- a/apps/web/src/components/chat/PanelLayoutControls.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { PanelLayoutControls } from "./PanelLayoutControls"; - -describe("PanelLayoutControls", () => { - it("keeps unavailable panel tooltip triggers interactive", () => { - const markup = renderToStaticMarkup( - {}} - onToggleRightPanel={() => {}} - />, - ); - - expect(markup.match(/data-slot="tooltip-trigger"/g)).toHaveLength(2); - expect(markup.match(/data-slot="tooltip-trigger"[^>]*>]*disabled=""/g)).toHaveLength( - 2, - ); - }); -}); diff --git a/apps/web/src/components/chat/TraitsPicker.test.ts b/apps/web/src/components/chat/TraitsPicker.test.ts index 5e9a3cf88..dbe97cdd5 100644 --- a/apps/web/src/components/chat/TraitsPicker.test.ts +++ b/apps/web/src/components/chat/TraitsPicker.test.ts @@ -167,7 +167,7 @@ describe("buildUnavailableModelOptionDescriptors", () => { ).toEqual([ { id: "variant", - label: "Variant", + label: "Reasoning", type: "select", options: [{ id: "max", label: "max" }], currentValue: "max", diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 414e3a7bd..580a2779a 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -43,7 +43,7 @@ const SAVED_OPTION_LABELS: Readonly> = { agent: "Agent", effort: "Effort", reasoningEffort: "Reasoning effort", - variant: "Variant", + variant: "Reasoning", }; function savedOptionLabel(id: string): string { diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts index 751aced7b..350ceba3a 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts @@ -177,7 +177,7 @@ describe("searchSlashCommandItems", () => { ]); }); - it("hides skills from slash completion after the first message line", () => { + it("hides provider commands from slash completion after the first message line", () => { const items = [ { id: "slash:model", @@ -186,6 +186,14 @@ describe("searchSlashCommandItems", () => { label: "/model", description: "Switch model", }, + { + id: "provider-slash-command:claudeAgent:compact", + type: "provider-slash-command", + provider: claudeDriver, + command: { name: "compact" }, + label: "/compact", + description: "Compact the conversation", + }, { id: "skill:claudeAgent:unslop", type: "skill", @@ -204,9 +212,11 @@ describe("searchSlashCommandItems", () => { expect(slashCommandItemsForPromptPosition(items, false).map((item) => item.id)).toEqual([ "slash:model", + "skill:claudeAgent:unslop", ]); expect(slashCommandItemsForPromptPosition(items, true).map((item) => item.id)).toEqual([ "slash:model", + "provider-slash-command:claudeAgent:compact", "skill:claudeAgent:unslop", ]); }); diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.ts b/apps/web/src/components/chat/composerSlashCommandSearch.ts index 1578e0ec6..0d33a3316 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.ts @@ -12,6 +12,12 @@ type SlashSearchItem = Extract< { type: "slash-command" | "provider-slash-command" | "skill" } >; +/** + * A provider expands a slash command only when it opens the whole message; + * anywhere else it reaches the agent as literal text, so it is not offered + * there. Built-ins apply locally on selection and skills insert a `$` mention + * the server dispatches from any position, so both stay available. + */ export function slashCommandItemsForPromptPosition( items: ReadonlyArray, isAtPromptStart: boolean, @@ -19,7 +25,7 @@ export function slashCommandItemsForPromptPosition( if (isAtPromptStart) { return [...items]; } - return items.filter((item) => item.type !== "skill"); + return items.filter((item) => item.type !== "provider-slash-command"); } function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null { diff --git a/apps/web/src/components/clerk/BrowserManagedAuthShell.tsx b/apps/web/src/components/clerk/BrowserManagedAuthShell.tsx new file mode 100644 index 000000000..285e9f7c7 --- /dev/null +++ b/apps/web/src/components/clerk/BrowserManagedAuthShell.tsx @@ -0,0 +1,25 @@ +import { ClerkProvider } from "@clerk/react"; +import type { ReactNode } from "react"; + +import { ManagedRelayAuthProvider } from "../../cloud/managedAuth"; +import { clerkAppearance } from "./clerkAppearance"; + +/** + * Browser half of the managed-auth boundary, loaded lazily from the entry so + * cloudless local mode never downloads a Clerk runtime. The browser provider + * stays small on its own: it hotloads clerk-js at runtime instead of bundling + * it. + */ +export default function BrowserManagedAuthShell({ + publishableKey, + children, +}: { + readonly publishableKey: string; + readonly children: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/clerk/ElectronManagedAuthShell.tsx b/apps/web/src/components/clerk/ElectronManagedAuthShell.tsx new file mode 100644 index 000000000..3030c2a39 --- /dev/null +++ b/apps/web/src/components/clerk/ElectronManagedAuthShell.tsx @@ -0,0 +1,26 @@ +import { passkeys } from "@clerk/electron/passkeys"; +import { ClerkProvider } from "@clerk/electron/react"; +import type { ReactNode } from "react"; + +import { ManagedRelayAuthProvider } from "../../cloud/managedAuth"; +import { clerkAppearance } from "./clerkAppearance"; + +/** + * Electron half of the managed-auth boundary. The Electron provider statically + * bundles the full clerk-js runtime, so this module must only ever load + * lazily, and only inside the desktop shell — importing it eagerly would put + * clerk-js back into every client's startup graph. + */ +export default function ElectronManagedAuthShell({ + publishableKey, + children, +}: { + readonly publishableKey: string; + readonly children: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx new file mode 100644 index 000000000..e97a46a2a --- /dev/null +++ b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx @@ -0,0 +1,105 @@ +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { DesktopAppActivationRequest } from "@t3tools/contracts"; +import { useEffect, useEffectEvent, useRef } from "react"; + +import { handleDesktopAppActivationRequest } from "../../desktopAppActivation"; +import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; +import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths"; +import { newProjectId } from "../../lib/utils"; +import { resolveDefaultProviderModelSelection } from "../../providerInstances"; +import { readProjects, waitForProject } from "../../state/entities"; +import { usePrimaryEnvironment } from "../../state/environments"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; +import { environmentShell } from "../../state/shell"; +import { useAtomCommand } from "../../state/use-atom-command"; + +export function DesktopAppActivationCoordinator() { + const primaryEnvironment = usePrimaryEnvironment(); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const openThread = useNewThreadHandler(); + const queueRef = useRef(Promise.resolve()); + const activation = window.desktopBridge?.appActivation; + const shell = useEnvironmentQuery( + primaryEnvironment === null + ? null + : environmentShell.stateAtom(primaryEnvironment.environmentId), + ); + const ready = + activation !== undefined && + primaryEnvironment?.connection.phase === "connected" && + primaryEnvironment.serverConfig !== null && + shell.data?.snapshot._tag === "Some"; + + const processRequest = useEffectEvent(async (request: DesktopAppActivationRequest) => + handleDesktopAppActivationRequest(request, { + getTarget: () => { + if ( + primaryEnvironment?.connection.phase !== "connected" || + primaryEnvironment.serverConfig === null + ) { + return null; + } + return { + environmentId: primaryEnvironment.environmentId, + platform: primaryEnvironment.serverConfig.environment.platform.os, + }; + }, + findProject: (environmentId, workspaceRoot) => + findProjectByPath( + readProjects().filter((project) => project.environmentId === environmentId), + workspaceRoot, + ) ?? null, + createProject: async (environmentId, workspaceRoot) => { + const projectId = newProjectId(); + const providers = + primaryEnvironment?.environmentId === environmentId + ? (primaryEnvironment.serverConfig?.providers ?? []) + : []; + const result = await createProject({ + environmentId, + input: { + projectId, + title: inferProjectTitleFromPath(workspaceRoot), + workspaceRoot, + createWorkspaceRootIfMissing: false, + defaultModelSelection: resolveDefaultProviderModelSelection(providers, null), + }, + }); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + throw error instanceof Error ? error : new Error("T3 Code could not add the project."); + } + return projectId; + }, + waitForProject: async (projectRef) => { + await waitForProject(projectRef); + }, + openThread: (projectRef) => openThread(projectRef), + }), + ); + + useEffect(() => { + if (!ready || activation === undefined) return; + + let subscribed = true; + const unsubscribe = activation.onRequest((request) => { + queueRef.current = queueRef.current.then(async () => { + const response = await processRequest(request); + await activation.complete(response); + }); + queueRef.current = queueRef.current.catch(() => undefined); + }); + // Skip readiness if React runs cleanup before this subscription can receive requests. + queueMicrotask(() => { + if (subscribed) void activation.setReady(true).catch(() => undefined); + }); + return () => { + subscribed = false; + void activation.setReady(false).catch(() => undefined); + unsubscribe(); + }; + }, [activation, ready]); + + return null; +} diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 7e96575f7..63ed2d851 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -7,6 +7,7 @@ import { getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, getDesktopUpdateInstallConfirmationMessage, + getDesktopUpdateReleaseHistoryUrl, getDesktopUpdateReleaseUrl, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, @@ -26,6 +27,7 @@ const baseState: DesktopUpdateState = { availableVersion: null, downloadedVersion: null, releaseNotes: [], + omittedReleaseCount: 0, downloadPercent: null, checkedAt: null, message: null, @@ -202,6 +204,12 @@ describe("desktop update UI helpers", () => { expect(getDesktopUpdateReleaseUrl(" ")).toBeNull(); }); + it("builds the release history URL", () => { + expect(getDesktopUpdateReleaseHistoryUrl()).toBe( + "https://github.com/pingdotgg/t3code/releases", + ); + }); + it("toasts only for actionable updater errors", () => { expect( shouldToastDesktopUpdateActionResult({ diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index f799f6561..b4afe7fda 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -3,7 +3,8 @@ import { isWindowsPlatform } from "../lib/utils"; export type DesktopUpdateButtonAction = "download" | "install" | "none"; -const DESKTOP_RELEASE_TAG_URL = "https://github.com/pingdotgg/t3code/releases/tag"; +const DESKTOP_RELEASE_HISTORY_URL = "https://github.com/pingdotgg/t3code/releases"; +const DESKTOP_RELEASE_TAG_URL = `${DESKTOP_RELEASE_HISTORY_URL}/tag`; /** * The main process fills `downloadedVersion` from the updater's `update-downloaded` @@ -21,6 +22,10 @@ export function getDesktopUpdateReleaseUrl(version: string | null): string | nul return `${DESKTOP_RELEASE_TAG_URL}/v${encodeURIComponent(normalizedVersion)}`; } +export function getDesktopUpdateReleaseHistoryUrl(): string { + return DESKTOP_RELEASE_HISTORY_URL; +} + export function resolveDesktopUpdateButtonAction( state: DesktopUpdateState, ): DesktopUpdateButtonAction { diff --git a/apps/web/src/components/desktopUpdate.toast.test.tsx b/apps/web/src/components/desktopUpdate.toast.test.tsx index 369a3cdf4..0d1ba3961 100644 --- a/apps/web/src/components/desktopUpdate.toast.test.tsx +++ b/apps/web/src/components/desktopUpdate.toast.test.tsx @@ -50,6 +50,7 @@ function downloadedState(overrides: Partial = {}): DesktopUp availableVersion: "0.0.30", downloadedVersion: "0.0.30", releaseNotes: [], + omittedReleaseCount: 0, downloadPercent: 100, checkedAt: null, message: null, diff --git a/apps/web/src/components/desktopUpdate.toast.tsx b/apps/web/src/components/desktopUpdate.toast.tsx index 4e55f3a28..0c4991c17 100644 --- a/apps/web/src/components/desktopUpdate.toast.tsx +++ b/apps/web/src/components/desktopUpdate.toast.tsx @@ -9,6 +9,18 @@ import { toastManager } from "./ui/toast"; type DesktopUpdateShell = Pick; +export async function openDesktopUpdateReleaseNotes( + shell: DesktopUpdateShell | undefined, + releaseUrl: string, +): Promise { + try { + if (shell && (await shell.openExternal(releaseUrl))) return; + } catch { + // Surface rejected IPC calls through the same user-visible fallback. + } + toastManager.add({ type: "error", title: "Unable to open release notes" }); +} + function ReleaseNotesLink({ shell, releaseUrl, @@ -20,14 +32,7 @@ function ReleaseNotesLink({
+ ) : null; // Read from the whole conversation, not the window shown below it: a verdict older than the // last thirty comments still stands. const reviewOutcomes = latestPullRequestReviewOutcomes(detail.comments, detail.commits); @@ -783,22 +795,7 @@ export function PullRequestSummaryTab({

No comments yet.

) : (
- {hiddenCommentCount > 0 ? ( - // Hundreds of comments are hundreds of markdown renders, and the ones worth - // opening a pull request for are the recent ones. The rest are one press away and - // stay rendered once asked for. - - ) : null} + {commentOrder === "oldest" ? showOldestCommentsButton : null} {visibleComments.map((comment) => { const thread = threadByCommentId.get(comment.id); const body = visibleBody(comment.body); @@ -910,6 +907,7 @@ export function PullRequestSummaryTab({ ); })} + {commentOrder === "newest" ? showOldestCommentsButton : null}
)} diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index e8ec1ca53..b00e21daf 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -25,6 +25,7 @@ import { orderPullRequestComments, pullRequestActionMenuHasGroup, pullRequestActionNeedsHostRefresh, + pullRequestCheckoutCommand, pullRequestComposerTarget, pullRequestFindingKey, pullRequestHandoffLabels, @@ -38,6 +39,23 @@ import { } from "./pullRequestDetail.logic"; import type { ReviewCommentContext } from "~/reviewCommentContext"; +describe("pull request checkout commands", () => { + it.each([ + ["github", "feature", null, "gh pr checkout 42"], + ["gitlab", "feature", null, "glab mr checkout 42"], + ["azure-devops", "feature", null, "az repos pr checkout --id 42"], + [ + "bitbucket", + "feature/checkout", + "maria/t3code", + "git clone --single-branch --branch feature/checkout https://bitbucket.org/maria/t3code.git t3code-pr-42", + ], + ["unknown", "feature", null, null], + ] as const)("builds the %s command", (provider, branch, repository, expected) => { + expect(pullRequestCheckoutCommand(provider, 42, branch, repository)).toBe(expected); + }); +}); + const TIMELINE_SOURCE: Pick< PullRequestDetailView, "createdAt" | "author" | "commits" | "comments" | "mergedAt" | "closedAt" diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index ee81f1aeb..d8b3700ba 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -11,11 +11,43 @@ import type { PullRequestReviewThread, PullRequestState, PullRequestUpdateMethod, + SourceControlProviderKind, VcsRef, } from "@t3tools/contracts"; import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; +const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; +const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; + +export function pullRequestCheckoutCommand( + provider: SourceControlProviderKind, + number: number, + headBranch: string, + headRepositoryNameWithOwner?: string | null, +): string | null { + switch (provider) { + case "github": + return `gh pr checkout ${number}`; + case "gitlab": + return `glab mr checkout ${number}`; + case "azure-devops": + return `az repos pr checkout --id ${number}`; + case "bitbucket": { + if ( + !headRepositoryNameWithOwner || + !bitbucketRepositoryName.test(headRepositoryNameWithOwner) || + !safeShellArgument.test(headBranch) + ) { + return null; + } + return `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}`; + } + case "unknown": + return null; + } +} + /** Activity changes only when the same host resource reports a newer revision. */ export function shouldRefreshPullRequestActivity( previous: { readonly key: string; readonly updatedAt: string } | null, diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts index b39cfd9ff..203be64ee 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts @@ -50,9 +50,9 @@ describe("isLineInFileDiff", () => { describe("isFileDiffCollapsed", () => { const NO_TOGGLES: ReadonlySet = new Set(); - it("folds every file before the reader has touched anything", () => { - expect(isFileDiffCollapsed("a.ts", null, NO_TOGGLES)).toBe(true); - expect(isFileDiffCollapsed("b.ts", null, NO_TOGGLES)).toBe(true); + it("opens every file before the reader has touched anything", () => { + expect(isFileDiffCollapsed("a.ts", null, NO_TOGGLES)).toBe(false); + expect(isFileDiffCollapsed("b.ts", null, NO_TOGGLES)).toBe(false); }); it("opens every file once the toolbar has asked for it", () => { @@ -66,12 +66,12 @@ describe("isFileDiffCollapsed", () => { expect(isFileDiffCollapsed("b.ts", "folded", NO_TOGGLES)).toBe(true); }); - it("keeps a file the reader opened open as the next slice arrives", () => { - // The file keys grow with every slice, so the answer for one already open must not depend on - // how many of them there are by then. + it("keeps a file the reader folded closed as the next slice arrives", () => { + // The file keys grow with every slice, so the answer for one already folded must not depend + // on how many of them there are by then. const toggled = new Set(["b.ts"]); - expect(isFileDiffCollapsed("b.ts", null, toggled)).toBe(false); - expect(isFileDiffCollapsed("c.ts", null, toggled)).toBe(true); + expect(isFileDiffCollapsed("b.ts", null, toggled)).toBe(true); + expect(isFileDiffCollapsed("c.ts", null, toggled)).toBe(false); }); it("still answers to a toggle after either toolbar press", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts index b3c19c4fe..a286a4552 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts @@ -30,15 +30,14 @@ export type DiffFoldOverride = "expanded" | "folded" | null; * A diff arrives a slice at a time, so the reader's own choices are kept as the difference from * what the toolbar last said rather than as the set of folded files: a file that has not loaded * yet cannot be in a set, and would otherwise land expanded moments after the reader folded - * everything. Folded is the starting point whatever the change's size, because laying out every - * file of it costs the reader the seconds before the tab is usable and buries the file they came - * for among the ones they did not. + * everything. Files start expanded so opening the Code tab immediately shows the change; the + * reader can still fold individual files or the whole diff from the toolbar. */ export function isFileDiffCollapsed( fileKey: string, foldOverride: DiffFoldOverride, toggledFileKeys: ReadonlySet, ): boolean { - const foldedByDefault = foldOverride !== "expanded"; + const foldedByDefault = foldOverride === "folded"; return toggledFileKeys.has(fileKey) ? !foldedByDefault : foldedByDefault; } diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index d12a1076d..bccad8c9f 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,12 +1,16 @@ -import { - ChevronsLeftRightEllipsisIcon, - PlusIcon, - QrCodeIcon, - RefreshCwIcon, - TerminalIcon, -} from "lucide-react"; +import { ChevronsLeftRightEllipsisIcon, PlusIcon, QrCodeIcon, TerminalIcon } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; -import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react"; +import { + type KeyboardEvent, + type ReactNode, + memo, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; import { AuthAccessReadScope, AuthAccessWriteScope, @@ -54,6 +58,15 @@ import { } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { Input } from "../ui/input"; +import { CommandShortcut } from "../ui/command"; +import { + Autocomplete, + AutocompleteEmpty, + AutocompleteInput, + AutocompleteItem, + AutocompleteList, + AutocompletePopup, +} from "../ui/autocomplete"; import { Checkbox } from "../ui/checkbox"; import { Dialog, @@ -118,7 +131,7 @@ import { desktopNetworkAccessStateAtom, refreshDesktopNetworkAccessState, } from "~/state/desktopNetworkAccess"; -import { desktopSshHostsStateAtom } from "~/state/desktopSshHosts"; +import { desktopSshHostsStateAtom, filterDiscoveredSshHosts } from "~/state/desktopSshHosts"; import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState"; import { type EnvironmentPresentation, @@ -126,11 +139,17 @@ import { usePrimaryEnvironment, } from "~/state/environments"; import { useAtomCommand } from "../../state/use-atom-command"; -import { serverEnvironment } from "~/state/server"; +import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, +} from "../../keybindings"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray = []; @@ -1436,10 +1455,12 @@ function SavedBackendListRow({ : null } /> -

{environment.label}

+

+ {environment.label} +

{metadataBits.length > 0 ? ( -

{metadataBits.join(" · ")}

+

{metadataBits.join(" · ")}

) : null} {serverUpdateState.status !== "idle" ? (
@@ -1538,46 +1559,6 @@ function SavedBackendListRow({ ); } -interface DesktopSshHostRowProps { - target: DesktopDiscoveredSshHost; - connectingHostAlias: string | null; - onConnect: (target: DesktopDiscoveredSshHost) => void; -} - -const DesktopSshHostRow = memo(function DesktopSshHostRow({ - target, - connectingHostAlias, - onConnect, -}: DesktopSshHostRowProps) { - const address = formatDesktopSshTarget(target); - const showAddress = address !== target.alias; - const buttonLabel = connectingHostAlias === target.alias ? "Adding…" : "Add environment"; - - return ( -
-
-
-

{target.alias}

- {showAddress ?

{address}

: null} -
-
- -
-
-
- ); -}); - function CloudLinkSwitch({ checked, disabled, @@ -1746,6 +1727,7 @@ function CloudRemoteEnvironmentRows({ export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); @@ -1769,24 +1751,6 @@ export function ConnectionsSettings() { .toSorted((left, right) => left.label.localeCompare(right.label)), [environments], ); - const savedDesktopSshEnvironmentsByAlias = useMemo( - () => - savedEnvironments.reduce>( - (accumulator, environment) => { - const profile = environment.entry.profile; - if ( - environment.entry.target._tag === "SshConnectionTarget" && - Option.isSome(profile) && - profile.value._tag === "SshConnectionProfile" - ) { - accumulator[profile.value.target.alias] = environment; - } - return accumulator; - }, - {}, - ), - [savedEnvironments], - ); const savedDesktopSshEnvironmentKeys = useMemo(() => { const keys = new Set(); for (const environment of savedEnvironments) { @@ -1804,9 +1768,6 @@ export function ConnectionsSettings() { } return keys; }, [savedEnvironments]); - const [sshConnectionError, setSshConnectionError] = useState(null); - const [connectingSshHostAlias, setConnectingSshHostAlias] = useState(null); - const [desktopServerExposureMutationError, setDesktopServerExposureMutationError] = useState< string | null >(null); @@ -1827,6 +1788,9 @@ export function ConnectionsSettings() { const [savedBackendSshHost, setSavedBackendSshHost] = useState(""); const [savedBackendSshUsername, setSavedBackendSshUsername] = useState(""); const [savedBackendSshPort, setSavedBackendSshPort] = useState(""); + const [sshHostSuggestionsOpen, setSshHostSuggestionsOpen] = useState(false); + // Tracks the arrow-key/hover highlight so Enter selects it instead of submitting the typed text. + const highlightedSshHostRef = useRef(undefined); const [savedBackendError, setSavedBackendError] = useState(null); const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false); const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] = @@ -1892,11 +1856,17 @@ export function ConnectionsSettings() { const desktopNetworkAccess = useEnvironmentQuery( canManageLocalBackend && desktopBridge ? desktopNetworkAccessStateAtom : null, ); + const isSshDiscoveryActive = + desktopBridge !== undefined && addBackendDialogOpen && savedBackendMode === "ssh"; const desktopSshHosts = useEnvironmentQuery( - desktopBridge && addBackendDialogOpen && savedBackendMode === "ssh" - ? desktopSshHostsStateAtom - : null, + isSshDiscoveryActive ? desktopSshHostsStateAtom : null, ); + // The discovery atom is kept alive across dialog opens, so re-read SSH config + // each time the SSH tab is shown; stale hosts stay visible while it refreshes. + const refreshDesktopSshHosts = desktopSshHosts.refresh; + useEffect(() => { + if (isSshDiscoveryActive) refreshDesktopSshHosts(); + }, [isSshDiscoveryActive, refreshDesktopSshHosts]); const desktopWsl = useEnvironmentQuery( canManageLocalBackend && desktopBridge ? desktopWslStateAtom : null, ); @@ -1915,10 +1885,15 @@ export function ConnectionsSettings() { }), [discoveredSshHosts, savedDesktopSshEnvironmentKeys], ); - const hasLoadedDiscoveredSshHosts = - desktopSshHosts.data !== null || desktopSshHosts.error !== null; - const isLoadingDiscoveredSshHosts = desktopSshHosts.isPending; - const discoveredSshHostsError = sshConnectionError ?? desktopSshHosts.error; + const filteredDiscoveredSshHosts = useMemo( + () => filterDiscoveredSshHosts(unsavedDiscoveredSshHosts, savedBackendSshHost), + [savedBackendSshHost, unsavedDiscoveredSshHosts], + ); + const isLoadingDiscoveredSshHosts = desktopSshHosts.isPending && desktopSshHosts.data === null; + const discoveredSshHostsError = desktopSshHosts.error; + const hasSshHostSuggestionContent = + desktopBridge !== undefined && + (isLoadingDiscoveredSshHosts || unsavedDiscoveredSshHosts.length > 0); const desktopServerExposureState = desktopNetworkAccess.data?.serverExposureState ?? null; const desktopAdvertisedEndpoints = desktopNetworkAccess.data?.advertisedEndpoints ?? EMPTY_ADVERTISED_ENDPOINTS; @@ -2142,23 +2117,11 @@ export function ConnectionsSettings() { } }, []); - const handleAddSavedBackend = useCallback(async () => { - if (savedBackendMode === "ssh") { + // Shared by manual SSH submission and discovered-host selection. + const connectSavedBackendSshTarget = useCallback( + async (target: DesktopSshEnvironmentTarget) => { setIsAddingSavedBackend(true); setSavedBackendError(null); - let target: DesktopSshEnvironmentTarget; - try { - target = parseManualDesktopSshTarget({ - host: savedBackendSshHost, - username: savedBackendSshUsername, - port: savedBackendSshPort, - }); - } catch (error) { - setSavedBackendError(formatDesktopSshConnectionError(error)); - setIsAddingSavedBackend(false); - return; - } - const result = await connectSshEnvironment({ target, label: "" }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { @@ -2180,6 +2143,25 @@ export function ConnectionsSettings() { description: `${target.alias} is ready over an SSH-managed tunnel.`, }); setIsAddingSavedBackend(false); + }, + [connectSshEnvironment], + ); + + const handleAddSavedBackend = useCallback(async () => { + if (savedBackendMode === "ssh") { + let target: DesktopSshEnvironmentTarget; + try { + target = parseManualDesktopSshTarget({ + host: savedBackendSshHost, + username: savedBackendSshUsername, + port: savedBackendSshPort, + }); + } catch (error) { + setSavedBackendError(formatDesktopSshConnectionError(error)); + return; + } + + await connectSavedBackendSshTarget(target); return; } @@ -2237,7 +2219,7 @@ export function ConnectionsSettings() { setIsAddingSavedBackend(false); }, [ connectPairing, - connectSshEnvironment, + connectSavedBackendSshTarget, savedBackendHost, savedBackendMode, savedBackendPairingCode, @@ -2246,6 +2228,92 @@ export function ConnectionsSettings() { savedBackendSshUsername, ]); + const handleSavedBackendSshFieldKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter" && savedBackendSshHost.trim().length > 0) { + event.preventDefault(); + void handleAddSavedBackend(); + } + }, + [handleAddSavedBackend, savedBackendSshHost], + ); + + // Resolves a picked alias before connecting it through the manual SSH flow. + const handleSelectSshHostSuggestion = useCallback( + async (target: DesktopDiscoveredSshHost) => { + if (isAddingSavedBackend || !desktopBridge) return; + + setIsAddingSavedBackend(true); + setSavedBackendError(null); + setSavedBackendSshHost(target.alias); + let resolved: DesktopSshEnvironmentTarget; + try { + resolved = await desktopBridge.resolveSshHost(target.alias); + } catch (error) { + setSavedBackendError(formatDesktopSshConnectionError(error)); + setIsAddingSavedBackend(false); + return; + } + setSavedBackendSshUsername(resolved.username ?? ""); + setSavedBackendSshPort(resolved.port === null ? "" : String(resolved.port)); + await connectSavedBackendSshTarget(resolved); + }, + [connectSavedBackendSshTarget, desktopBridge, isAddingSavedBackend], + ); + + const handleSavedBackendSshHostKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + + // The popup only renders when there is content, so an "open" flag alone is not enough. + const isSshHostPopupVisible = sshHostSuggestionsOpen && hasSshHostSuggestionContent; + if (isSshHostPopupVisible) { + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: { modelPickerOpen: false }, + }); + const index = threadJumpIndexFromCommand(command ?? ""); + const target = index === null ? undefined : filteredDiscoveredSshHosts[index]; + if (target) { + event.preventDefault(); + event.stopPropagation(); + setSshHostSuggestionsOpen(false); + void handleSelectSshHostSuggestion(target); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + return; + } + } + + // A highlighted row means Enter belongs to the autocomplete, which selects it. + const hasHighlightedSshHost = + isSshHostPopupVisible && highlightedSshHostRef.current !== undefined; + if ( + !event.defaultPrevented && + !hasHighlightedSshHost && + event.key === "Enter" && + savedBackendSshHost.trim().length > 0 + ) { + event.preventDefault(); + void handleAddSavedBackend(); + } + }, + [ + filteredDiscoveredSshHosts, + handleAddSavedBackend, + handleSelectSshHostSuggestion, + hasSshHostSuggestionContent, + keybindings, + savedBackendSshHost, + sshHostSuggestionsOpen, + ], + ); + const handleConnectSavedBackend = useCallback( async (environmentId: EnvironmentId) => { setSavedBackendError(null); @@ -2288,46 +2356,6 @@ export function ConnectionsSettings() { [removeEnvironment], ); - const handleConnectSshHost = useCallback( - async (target: DesktopSshEnvironmentTarget, label?: string) => { - setConnectingSshHostAlias(target.alias); - if (savedBackendMode === "ssh") { - setSavedBackendError(null); - } else { - setSshConnectionError(null); - } - const result = await connectSshEnvironment({ - target, - ...(label === undefined ? {} : { label }), - }); - setConnectingSshHostAlias(null); - if (result._tag === "Success") { - setSavedBackendSshHost(""); - setSavedBackendSshUsername(""); - setSavedBackendSshPort(""); - setAddBackendDialogOpen(false); - toastManager.add({ - type: "success", - title: savedDesktopSshEnvironmentsByAlias[target.alias] - ? "Environment reconnected" - : "Environment connected", - description: `${label?.trim() || target.alias} is ready over an SSH-managed tunnel.`, - }); - return; - } - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - const message = formatDesktopSshConnectionError(error); - if (savedBackendMode === "ssh") { - setSavedBackendError(message); - } else { - setSshConnectionError(message); - } - } - }, - [connectSshEnvironment, savedBackendMode, savedDesktopSshEnvironmentsByAlias], - ); - const visibleDesktopPairingLinks = desktopPairingLinks; const tailscaleHttpsEndpoint = useMemo( () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null, @@ -2473,24 +2501,90 @@ export function ConnectionsSettings() { const renderSshFields = () => (
-
); const renderNetworkAccessToggle = () => ( diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 90eaaef99..dd6750b0c 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -12,6 +12,7 @@ import { shortcutToKeybindingInput, unknownWhenVariables, whenAstToExpression, + whenNodeRemoveLabel, } from "./KeybindingsSettings.logic"; describe("KeybindingsSettings.logic", () => { @@ -120,6 +121,19 @@ describe("KeybindingsSettings.logic", () => { }); }); + it("describes the scope of each visual expression removal", () => { + const condition = { type: "identifier", name: "terminalFocus" } as const; + const negatedCondition = { type: "not", node: condition } as const; + const group = { type: "and", left: condition, right: negatedCondition } as const; + const negatedGroup = { type: "not", node: group } as const; + + expect(whenNodeRemoveLabel(group, 0)).toBe("Clear all conditions"); + expect(whenNodeRemoveLabel(condition, 1)).toBe("Remove condition"); + expect(whenNodeRemoveLabel(negatedCondition, 1)).toBe("Remove condition"); + expect(whenNodeRemoveLabel(group, 1)).toBe("Remove group and its conditions"); + expect(whenNodeRemoveLabel(negatedGroup, 1)).toBe("Remove group and its conditions"); + }); + it("formats static and project script command labels", () => { expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle"); expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle"); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 8c15111e1..d987bc7a8 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -68,6 +68,14 @@ export function whenAstToExpression(node: KeybindingWhenNode | undefined): strin } } +export function whenNodeRemoveLabel(node: KeybindingWhenNode, depth: number): string { + if (depth === 0) return "Clear all conditions"; + if (node.type === "identifier" || (node.type === "not" && node.node.type === "identifier")) { + return "Remove condition"; + } + return "Remove group and its conditions"; +} + function wrapWhenExpression(node: KeybindingWhenNode): string { if (node.type === "identifier" || node.type === "not") return whenAstToExpression(node); return `(${whenAstToExpression(node)})`; diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 33ae53770..7e23d8779 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -67,6 +67,7 @@ import { type WhenVariableOption, unknownWhenVariables, whenAstToExpression, + whenNodeRemoveLabel, } from "./KeybindingsSettings.logic"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; @@ -349,6 +350,37 @@ function WhenVariableSelect({ ); } +function WhenExpressionRemoveButton({ + label, + className, + onRemove, +}: { + label: string; + className?: string | undefined; + onRemove: () => void; +}) { + return ( + + + } + > + + + {label} + + ); +} + function WhenExpressionNodeEditor({ node, variables, @@ -388,16 +420,10 @@ function WhenExpressionNodeEditor({ onChange={(value) => onChange(setConditionIdentifier(node, value))} /> {onRemove ? ( - + ) : null}
); @@ -423,16 +449,11 @@ function WhenExpressionNodeEditor({ Not {onRemove ? ( - + ) : null}
@@ -546,16 +567,11 @@ function WhenExpressionNodeEditor({ Group {onRemove ? ( - + ) : null}
diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index dd9c89943..3a9d9c9e1 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -700,8 +700,10 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ] : [`This removes ${members.length} grouped project entries.`]), ...(projectThreads.length > 0 - ? ["This permanently clears conversation history for those threads."] - : []), + ? [ + "This permanently clears conversation history for those threads and any archived threads.", + ] + : ["This permanently clears any archived conversation history."]), isWholeGroup ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", @@ -723,7 +725,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { environmentId: member.environmentId, input: { projectId: member.id, - ...(memberThreads.length > 0 ? { force: true } : {}), + force: true, }, }), () => undefined, @@ -769,7 +771,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { return ( <> - + } /> - - - - {script.name} - - {script.command} - + {script.name} {script.runOnWorktreeCreate ? ( setup @@ -1132,6 +1128,9 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ) : null} } + description={ + {script.command} + } control={ <> {shortcutLabel ? ( diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 69d54cf57..f09918a56 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1008,7 +1008,7 @@ export function ProviderInstanceCard({ aria-hidden className={cn( "provider-update-marker size-3.5 motion-reduce:animate-none", - versionAdvisory.emphasis === "strong" ? "text-warning" : "text-update-foreground", + versionAdvisory.emphasis === "strong" ? "text-warning" : "text-muted-foreground", )} /> Update available @@ -1115,7 +1115,7 @@ export function ProviderInstanceCard({ "size-5 rounded-sm p-0", versionAdvisory.emphasis === "strong" ? "text-warning hover:text-warning" - : "text-update-foreground hover:text-update-foreground", + : "text-muted-foreground hover:text-foreground", )} aria-label="Update available — view details" disabled={writeBlocked} @@ -1149,7 +1149,7 @@ export function ProviderInstanceCard({
- - + + ) : null} {isHidden ? ( hidden diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 640b72189..db454ca31 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -5,6 +5,7 @@ import { terminalThemeFromApp } from "../ThreadTerminalDrawer"; import { useTheme } from "../../hooks/useTheme"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; import { resolveDiffThemeName, type DiffThemeName } from "../../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; // The font previews are the real surfaces, not lookalikes: the composer's @@ -79,7 +80,7 @@ function loadDiffPreviewHtml(theme: DiffThemeName): Promise { if (promise === undefined) { promise = preloadPatchFile({ patch: DIFF_PREVIEW_PATCH, - options: { diffStyle: "unified", theme }, + options: { diffStyle: "unified", theme, preferredHighlighter: PREFERRED_HIGHLIGHTER }, }).then((results) => results.map((result) => result.prerenderedHTML)); diffPreviewHtmlByTheme.set(theme, promise); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index eb90a8064..504d01eac 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -383,7 +383,7 @@ function AboutVersionSection() { render={ + ); + return ( - - - - + { + if (open && !showReleaseNotesPopover) { + details.cancel(); + return; } - /> - 0 - ? // pointer-events-auto overrides the positioner's pointer-events-none so the - // release notes stay open (and scrollable) when the cursor moves into them. - "pointer-events-auto max-w-none text-balance" - : undefined - } - side="top" - style={ - showUpdateDetails - ? { - background: - "color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))", - borderColor: "var(--update-foreground)", - } - : undefined - } - variant={showUpdateDetails ? "glass" : "default"} - > - {showUpdateDetails && state ? ( - - ) : ( - tooltip - )} - - + handleSidebarUpdateReleaseNotesPopoverOpenChange(open, details); + }} + > + + + } + /> + {!showReleaseNotesPopover ? ( + + {tooltip} + + ) : null} + + {showReleaseNotesPopover && state ? ( + { + if ( + event.key === "Escape" && + releaseNotesPopupRef.current?.contains(document.activeElement) + ) { + suppressReleaseNotesFocusOpen.current = true; + } + }} + ref={releaseNotesPopupRef} + side="top" + tooltipStyle + > + + + ) : null} + ); } diff --git a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.test.tsx b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.test.tsx new file mode 100644 index 000000000..d229e2d3c --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.test.tsx @@ -0,0 +1,152 @@ +import type { DesktopUpdateState } from "@t3tools/contracts"; +import { isValidElement, type MouseEvent, type ReactElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + addToast: vi.fn(), +})); + +vi.mock("../ui/toast", () => ({ + toastManager: { add: testState.addToast }, +})); + +import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; + +type AnchorElement = ReactElement<{ + readonly children?: ReactNode; + readonly href?: string; + readonly onClick?: (event: MouseEvent) => void; +}>; + +const baseState: DesktopUpdateState = { + enabled: true, + status: "available", + channel: "nightly", + currentVersion: "0.0.35", + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + availableVersion: "0.0.36-nightly.3", + downloadedVersion: null, + releaseNotes: [], + omittedReleaseCount: 0, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, +}; + +function collectAnchors(node: ReactNode, anchors: AnchorElement[] = []): AnchorElement[] { + if (Array.isArray(node)) { + for (const child of node) collectAnchors(child, anchors); + return anchors; + } + if (!isValidElement(node)) return anchors; + + const element = node as ReactElement<{ readonly children?: ReactNode }>; + if (typeof element.type === "function") { + const render = element.type as (props: unknown) => ReactNode; + return collectAnchors(render(element.props), anchors); + } + if (element.type === "a") anchors.push(element as AnchorElement); + return collectAnchors(element.props.children, anchors); +} + +function textContent(node: ReactNode): string { + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(textContent).join(""); + if (!isValidElement(node)) return ""; + const element = node as ReactElement<{ readonly children?: ReactNode }>; + return textContent(element.props.children); +} + +function renderNotes(state: DesktopUpdateState, openExternal = vi.fn().mockResolvedValue(true)) { + return SidebarUpdateReleaseNotes({ + shell: { openExternal }, + state, + tooltip: "Update available", + }); +} + +describe("SidebarUpdateReleaseNotes", () => { + beforeEach(() => { + testState.addToast.mockReset(); + }); + + it("links each preview to its exact release and labels hidden changes", () => { + const anchors = collectAnchors( + renderNotes({ + ...baseState, + releaseNotes: [ + { version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }, + { version: "0.0.36-nightly.2", items: ["Change 2"], totalItems: 2 }, + { version: "0.0.36-nightly.1", items: ["Change 1", "Earlier"], totalItems: 4 }, + ], + }), + ); + + expect(anchors.map(({ props }) => props.href)).toEqual([ + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.3", + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.2", + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.1", + ]); + expect(anchors.map(({ props }) => textContent(props.children))).toEqual([ + "View release on GitHub", + "1 more change on GitHub", + "2 more changes on GitHub", + ]); + }); + + it("links omitted releases to release history", () => { + const anchors = collectAnchors( + renderNotes({ + ...baseState, + releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }], + omittedReleaseCount: 1, + }), + ); + + expect(anchors.at(-1)?.props.href).toBe("https://github.com/pingdotgg/t3code/releases"); + expect(textContent(anchors.at(-1)?.props.children)).toBe("1 older release on GitHub"); + }); + + it("shows plural history text for multiple omitted releases", () => { + const anchors = collectAnchors( + renderNotes({ + ...baseState, + releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }], + omittedReleaseCount: 3, + }), + ); + + expect(textContent(anchors.at(-1)?.props.children)).toBe("3 older releases on GitHub"); + }); + + it("reports a release link that fails to open", async () => { + const openExternal = vi.fn().mockResolvedValue(false); + const [anchor] = collectAnchors( + renderNotes( + { + ...baseState, + releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }], + }, + openExternal, + ), + ); + const preventDefault = vi.fn(); + + anchor?.props.onClick?.({ preventDefault } as unknown as MouseEvent); + + expect(preventDefault).toHaveBeenCalledOnce(); + await vi.waitFor(() => { + expect(openExternal).toHaveBeenCalledWith( + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.3", + ); + expect(testState.addToast).toHaveBeenCalledWith({ + type: "error", + title: "Unable to open release notes", + }); + }); + }); +}); diff --git a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx new file mode 100644 index 000000000..89204d1bd --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx @@ -0,0 +1,120 @@ +import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; +import { ExternalLinkIcon } from "lucide-react"; + +import { + getDesktopUpdateReleaseHistoryUrl, + getDesktopUpdateReleaseUrl, +} from "../desktopUpdate.logic"; +import { openDesktopUpdateReleaseNotes } from "../desktopUpdate.toast"; +import { Separator } from "../ui/separator"; + +type DesktopUpdateShell = Pick; + +function keyReleaseNoteItems(items: ReadonlyArray) { + const occurrences = new Map(); + return items.map((item) => { + const occurrence = occurrences.get(item) ?? 0; + occurrences.set(item, occurrence + 1); + return { item, key: JSON.stringify([item, occurrence]) }; + }); +} + +function ReleaseLink({ + children, + releaseUrl, + shell, +}: { + readonly children: string; + readonly releaseUrl: string; + readonly shell: DesktopUpdateShell | undefined; +}) { + return ( +
{ + event.preventDefault(); + void openDesktopUpdateReleaseNotes(shell, releaseUrl); + }} + > + {children} + + + ); +} + +export function SidebarUpdateReleaseNotes({ + shell, + state, + tooltip, +}: { + readonly shell: DesktopUpdateShell | undefined; + readonly state: DesktopUpdateState; + readonly tooltip: string; +}) { + if (state.channel !== "nightly" || state.releaseNotes.length === 0) { + return <>{tooltip}; + } + + return ( +
+
+ {state.status === "available" ? ( +
+
+ Update ready to download +
+ {state.availableVersion ? ( +
+ {state.availableVersion} +
+ ) : null} +
+ ) : ( +
{tooltip}
+ )} +
+
+ {state.releaseNotes.map((releaseNote, index) => { + const releaseUrl = getDesktopUpdateReleaseUrl(releaseNote.version); + const omittedItemCount = Math.max(0, releaseNote.totalItems - releaseNote.items.length); + const linkLabel = + omittedItemCount === 0 + ? "View release on GitHub" + : `${omittedItemCount} more ${omittedItemCount === 1 ? "change" : "changes"} on GitHub`; + + return ( +
+ {index > 0 && } +
+

+ {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`} +

+
    + {keyReleaseNoteItems(releaseNote.items).map(({ item, key }) => ( +
  • + {item} +
  • + ))} +
+ {releaseUrl ? ( + + {linkLabel} + + ) : null} +
+
+ ); + })} + {state.omittedReleaseCount > 0 ? ( +
+ + + {`${state.omittedReleaseCount} older ${state.omittedReleaseCount === 1 ? "release" : "releases"} on GitHub`} + +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/ui/anchoredCopyToast.ts b/apps/web/src/components/ui/anchoredCopyToast.ts new file mode 100644 index 000000000..df1ac579c --- /dev/null +++ b/apps/web/src/components/ui/anchoredCopyToast.ts @@ -0,0 +1,33 @@ +import type { RefObject } from "react"; +import { anchoredToastManager } from "./toast"; + +export const ANCHORED_COPY_TOAST_TIMEOUT_MS = 1000; + +export function showAnchoredCopySuccessToast(ref: RefObject) { + if (!ref.current) return; + anchoredToastManager.add({ + data: { + tooltipStyle: true, + }, + positionerProps: { + anchor: ref.current, + }, + timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS, + title: "Copied!", + }); +} + +export function showAnchoredCopyErrorToast(ref: RefObject, error: Error) { + if (!ref.current) return; + anchoredToastManager.add({ + data: { + tooltipStyle: true, + }, + positionerProps: { + anchor: ref.current, + }, + timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS, + title: "Failed to copy", + description: error.message, + }); +} diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx index e06d9b9a0..775d23d75 100644 --- a/apps/web/src/components/ui/badge.tsx +++ b/apps/web/src/components/ui/badge.tsx @@ -18,7 +18,9 @@ const badgeVariants = cva( default: "h-5.5 min-w-5.5 px-[calc(--spacing(1)-1px)] text-sm sm:h-4.5 sm:min-w-4.5 sm:text-xs", lg: "h-6.5 min-w-6.5 px-[calc(--spacing(1.5)-1px)] text-base sm:h-5.5 sm:min-w-5.5 sm:text-sm", - sm: "h-5 min-w-5 rounded-[.25rem] px-[calc(--spacing(1)-1px)] text-xs sm:h-4 sm:min-w-4 sm:text-[.625rem]", + // leading-none: with the inherited fractional leading the rounded font metrics + // leave the label sitting high in the fixed-height box, worse under renderer zoom. + sm: "h-5 min-w-5 rounded-[.25rem] px-[calc(--spacing(1)-1px)] text-xs leading-none sm:h-4 sm:min-w-4 sm:text-[.625rem]", }, variant: { default: "bg-primary text-primary-foreground [button&,a&]:hover:bg-primary/90", diff --git a/apps/web/src/components/ui/button.test.tsx b/apps/web/src/components/ui/button.test.tsx deleted file mode 100644 index 3faddf29d..000000000 --- a/apps/web/src/components/ui/button.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; -import { FlaskConicalIcon } from "lucide-react"; - -import { Button } from "./button"; - -describe("button geometry tokens", () => { - it("uses the shared control radius and an opaque semantic icon color", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain("rounded-[var(--control-radius)]"); - expect(html).toContain("[--control-icon-color:var(--contrast-muted-foreground)]"); - expect(html).toContain("text-[var(--control-icon-color)]"); - expect(html).not.toContain("opacity-80"); - }); - - it("keeps compact icon buttons square at every breakpoint", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain("size-7"); - expect(html).toContain("sm:size-6"); - }); - - it("owns shared compact and micro control geometry", () => { - const compact = renderToStaticMarkup(); - const microLabel = renderToStaticMarkup( - , - ); - const micro = renderToStaticMarkup( - , - ); - - expect(compact).toContain("h-7"); - expect(compact).toContain("rounded-md"); - expect(microLabel).toContain("text-[11px]"); - expect(microLabel).toContain("sm:text-[11px]"); - expect(microLabel).toContain("sm:[&_svg:not([class*='size-'])]:size-3"); - expect(micro).toContain("size-5"); - expect(micro).toContain("rounded-sm"); - expect(micro).toContain("text-muted-foreground"); - }); -}); diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 274eb00d7..b8d801f47 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -8,7 +8,7 @@ import type * as React from "react"; import { cn } from "~/lib/utils"; const buttonVariants = cva( - "[--control-icon-color:currentColor] [&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-[var(--control-radius)] border font-medium text-base outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--control-radius)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 sm:text-sm [&_svg:not([class*='text-'])]:text-[var(--control-icon-color)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "[--control-icon-color:currentColor] [&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-[var(--control-radius)] border font-medium text-base outline-none transition-[box-shadow,scale] active:scale-[0.97] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--control-radius)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 sm:text-sm [&_svg:not([class*='text-'])]:text-[var(--control-icon-color)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", { defaultVariants: { size: "default", diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx deleted file mode 100644 index f1428d13d..000000000 --- a/apps/web/src/components/ui/card.tsx +++ /dev/null @@ -1,196 +0,0 @@ -"use client"; - -import { mergeProps } from "@base-ui/react/merge-props"; -import { useRender } from "@base-ui/react/use-render"; - -import { cn } from "~/lib/utils"; - -function Card({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn( - "relative flex flex-col rounded-2xl border bg-card not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", - className, - ), - "data-slot": "card", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardFrame({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn( - "[--clip-top:-1rem] [--clip-bottom:-1rem] *:data-[slot=card]:first:[--clip-top:1px] *:data-[slot=card]:last:[--clip-bottom:1px] flex flex-col relative rounded-2xl border bg-card before:bg-muted/72 not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)] *:data-[slot=card]:-m-px *:not-last:data-[slot=card]:rounded-b-xl *:not-last:data-[slot=card]:before:rounded-b-[calc(var(--radius-xl)-1px)] *:not-first:data-[slot=card]:rounded-t-xl *:not-first:data-[slot=card]:before:rounded-t-[calc(var(--radius-xl)-1px)] *:data-[slot=card]:[clip-path:inset(var(--clip-top)_1px_var(--clip-bottom)_1px_round_calc(var(--radius-2xl)-1px))] *:data-[slot=card]:shadow-none *:data-[slot=card]:before:hidden *:data-[slot=card]:bg-clip-padding", - className, - ), - "data-slot": "card-frame", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardFrameHeader({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn("relative flex flex-col px-6 py-4", className), - "data-slot": "card-frame-header", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardFrameTitle({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn("font-semibold text-sm", className), - "data-slot": "card-frame-title", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardFrameDescription({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn("text-muted-foreground text-sm", className), - "data-slot": "card-frame-description", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardFrameFooter({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn("px-6 py-4", className), - "data-slot": "card-frame-footer", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardHeader({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn( - "grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 p-6 in-[[data-slot=card]:has(>[data-slot=card-panel])]:pb-4 has-data-[slot=card-action]:grid-cols-[1fr_auto]", - className, - ), - "data-slot": "card-header", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardTitle({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn("font-semibold text-lg leading-none", className), - "data-slot": "card-title", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardDescription({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn("text-muted-foreground text-sm", className), - "data-slot": "card-description", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardAction({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn( - "col-start-2 row-span-2 row-start-1 self-start justify-self-end inline-flex", - className, - ), - "data-slot": "card-action", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardPanel({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn( - "flex-1 p-6 in-[[data-slot=card]:has(>[data-slot=card-header]:not(.border-b))]:pt-0 in-[[data-slot=card]:has(>[data-slot=card-footer]:not(.border-t))]:pb-0", - className, - ), - "data-slot": "card-panel", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardFooter({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn( - "flex items-center p-6 in-[[data-slot=card]:has(>[data-slot=card-panel])]:pt-4", - className, - ), - "data-slot": "card-footer", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -export { - Card, - CardFrame, - CardFrameHeader, - CardFrameTitle, - CardFrameDescription, - CardFrameFooter, - CardAction, - CardDescription, - CardFooter, - CardHeader, - CardPanel, - CardPanel as CardContent, - CardTitle, -}; diff --git a/apps/web/src/components/ui/command.test.tsx b/apps/web/src/components/ui/command.test.tsx deleted file mode 100644 index bd03b2712..000000000 --- a/apps/web/src/components/ui/command.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { Command, CommandFooter, CommandInput } from "./command"; - -describe("command compact geometry", () => { - it("keeps shell selectors on the wrapper and direct-input padding on AutocompleteInput", () => { - const html = renderToStaticMarkup( - - - , - ); - const shellClass = html.match(/class="([^"]*px-\[var\(--command-shell-inset\)[^"]*)"/)?.[1]; - const inputClass = html.match(/class="([^"]*has-focus-visible:ring-0[^"]*)"/)?.[1]; - - expect(shellClass).toContain( - "[&_[data-slot=autocomplete-start-addon]]:ps-[calc(var(--command-shell-inset)+0.0625rem)]", - ); - expect(shellClass).not.toContain("sm:*:data-[slot=autocomplete-input]"); - expect(inputClass).toContain( - "sm:*:data-[slot=autocomplete-input]:ps-[calc(var(--command-shell-inset)+1.5rem)]!", - ); - }); - - it("uses the semantic footer inset without changing compact vertical padding", () => { - const html = renderToStaticMarkup(Shortcuts); - - expect(html).toContain("px-[var(--command-content-inset)]"); - expect(html).toContain("py-2.5"); - }); -}); diff --git a/apps/web/src/components/ui/field.tsx b/apps/web/src/components/ui/field.tsx deleted file mode 100644 index 1bf65b6c8..000000000 --- a/apps/web/src/components/ui/field.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import { Field as FieldPrimitive } from "@base-ui/react/field"; - -import { cn } from "~/lib/utils"; - -function Field({ className, ...props }: FieldPrimitive.Root.Props) { - return ( - - ); -} - -function FieldLabel({ className, ...props }: FieldPrimitive.Label.Props) { - return ( - - ); -} - -function FieldItem({ className, ...props }: FieldPrimitive.Item.Props) { - return ( - - ); -} - -function FieldDescription({ className, ...props }: FieldPrimitive.Description.Props) { - return ( - - ); -} - -function FieldError({ className, ...props }: FieldPrimitive.Error.Props) { - return ( - - ); -} - -const FieldControl = FieldPrimitive.Control; -const FieldValidity = FieldPrimitive.Validity; - -export { Field, FieldLabel, FieldDescription, FieldError, FieldControl, FieldItem, FieldValidity }; diff --git a/apps/web/src/components/ui/fieldset.tsx b/apps/web/src/components/ui/fieldset.tsx deleted file mode 100644 index 23763b982..000000000 --- a/apps/web/src/components/ui/fieldset.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { Fieldset as FieldsetPrimitive } from "@base-ui/react/fieldset"; - -import { cn } from "~/lib/utils"; - -function Fieldset({ className, ...props }: FieldsetPrimitive.Root.Props) { - return ( - - ); -} -function FieldsetLegend({ className, ...props }: FieldsetPrimitive.Legend.Props) { - return ( - - ); -} - -export { Fieldset, FieldsetLegend }; diff --git a/apps/web/src/components/ui/form.tsx b/apps/web/src/components/ui/form.tsx deleted file mode 100644 index 641fc2ee6..000000000 --- a/apps/web/src/components/ui/form.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import { Form as FormPrimitive } from "@base-ui/react/form"; - -import { cn } from "~/lib/utils"; - -function Form({ className, ...props }: FormPrimitive.Props) { - return ( - - ); -} - -export { Form }; diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 74f4a17b3..2815b3dad 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1415,17 +1415,18 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me"); }); - it("finalizes a promoted draft after the canonical thread route is active", () => { + it("moves composer edits made during promotion to the canonical thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); - store.setPrompt(draftId, "promote me"); markPromotedDraftThread(threadId); + store.setPrompt(draftId, "typed during setup"); finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); expect(draftByKey(draftId)).toBeUndefined(); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("typed during setup"); }); it("finalizes a matching materialized draft even when promotion was not pre-marked", () => { @@ -1438,6 +1439,7 @@ describe("composerDraftStore project draft thread mapping", () => { expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); expect(draftByKey(draftId)).toBeUndefined(); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("promote me"); }); it("updates branch context on an existing draft thread", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2bb7c1e17..b75980d4c 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1651,6 +1651,7 @@ function removeDraftThreadReferences( | "logicalProjectDraftThreadKeyByLogicalProjectKey" >, threadKey: string, + composerDestination?: ScopedThreadRef, ): Pick< ComposerDraftStoreState, | "draftThreadsByThreadKey" @@ -1665,7 +1666,11 @@ function removeDraftThreadReferences( const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } = state.draftThreadsByThreadKey; const { [threadKey]: removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey; - revokeDraftThreadPreviewUrls(removedComposerDraft); + if (composerDestination && removedComposerDraft) { + restDraftsByThreadKey[composerTargetKey(composerDestination)] = removedComposerDraft; + } else { + revokeDraftThreadPreviewUrls(removedComposerDraft); + } return { draftsByThreadKey: restDraftsByThreadKey, draftThreadsByThreadKey: restDraftThreadsByThreadKey, @@ -2892,10 +2897,10 @@ const composerDraftStore = create()( } set((state) => { const existing = state.draftThreadsByThreadKey[threadKey]; - if (!isDraftThreadPromoting(existing)) { + if (!existing || !isDraftThreadPromoting(existing)) { return state; } - return removeDraftThreadReferences(state, threadKey); + return removeDraftThreadReferences(state, threadKey, existing.promotedTo ?? undefined); }); }, clearDraftThread: (threadRef) => { diff --git a/apps/web/src/desktopAppActivation.test.ts b/apps/web/src/desktopAppActivation.test.ts new file mode 100644 index 000000000..e362391ed --- /dev/null +++ b/apps/web/src/desktopAppActivation.test.ts @@ -0,0 +1,107 @@ +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + handleDesktopAppActivationRequest, + type DesktopAppActivationDependencies, +} from "./desktopAppActivation"; + +const environmentId = EnvironmentId.make("primary"); +const existingProjectId = ProjectId.make("project-existing"); +const createdProjectId = ProjectId.make("project-created"); +const threadId = ThreadId.make("thread-1"); +const request = { + version: 1, + requestId: "request-1", + type: "open-workspace", + workspaceRoot: "/workspace/project", + platform: "linux", +} as const; + +function dependencies( + overrides: Partial = {}, +): DesktopAppActivationDependencies { + return { + getTarget: () => ({ environmentId, platform: "linux" }), + findProject: () => ({ + id: existingProjectId, + environmentId, + workspaceRoot: request.workspaceRoot, + }), + createProject: vi.fn(async () => createdProjectId), + waitForProject: vi.fn(async () => undefined), + openThread: vi.fn(async () => ({ threadId })), + ...overrides, + }; +} + +describe("desktop app activation", () => { + it("reuses an existing project and opens a new thread", async () => { + const deps = dependencies(); + + const response = await handleDesktopAppActivationRequest(request, deps); + + expect(deps.createProject).not.toHaveBeenCalled(); + expect(deps.openThread).toHaveBeenCalledWith({ environmentId, projectId: existingProjectId }); + expect(response).toEqual({ + version: 1, + requestId: request.requestId, + ok: true, + projectId: existingProjectId, + threadId, + }); + }); + + it("waits for a created project before it opens the thread", async () => { + const order: string[] = []; + const deps = dependencies({ + findProject: () => null, + createProject: vi.fn(async () => { + order.push("create"); + return createdProjectId; + }), + waitForProject: vi.fn(async () => { + order.push("project-event"); + }), + openThread: vi.fn(async () => { + order.push("open-thread"); + return { threadId }; + }), + }); + + const response = await handleDesktopAppActivationRequest(request, deps); + + expect(order).toEqual(["create", "project-event", "open-thread"]); + expect(response).toMatchObject({ ok: true, projectId: createdProjectId }); + }); + + it("rejects a Windows path when the primary environment is WSL", async () => { + const response = await handleDesktopAppActivationRequest( + { ...request, platform: "win32" }, + dependencies({ getTarget: () => ({ environmentId, platform: "linux" }) }), + ); + + expect(response).toMatchObject({ ok: false, code: "platform-mismatch" }); + }); + + it("returns a project error without opening a thread", async () => { + const openThread = vi.fn(async () => ({ threadId })); + const response = await handleDesktopAppActivationRequest( + request, + dependencies({ + findProject: () => null, + createProject: vi.fn(async () => { + throw new Error("Project path is not available."); + }), + openThread, + }), + ); + + expect(response).toMatchObject({ + ok: false, + code: "project-create-failed", + message: "Project path is not available.", + }); + expect(openThread).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/desktopAppActivation.ts b/apps/web/src/desktopAppActivation.ts new file mode 100644 index 000000000..e9d3a4d1d --- /dev/null +++ b/apps/web/src/desktopAppActivation.ts @@ -0,0 +1,119 @@ +import type { + DesktopAppActivationFailure, + DesktopAppActivationRequest, + DesktopAppActivationResponse, + EnvironmentId, + ExecutionEnvironmentPlatformOs, + ProjectId, + ScopedProjectRef, + ThreadId, +} from "@t3tools/contracts"; + +export interface DesktopAppActivationProject { + readonly id: ProjectId; + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; +} + +export interface DesktopAppActivationTarget { + readonly environmentId: EnvironmentId; + readonly platform: ExecutionEnvironmentPlatformOs; +} + +export interface DesktopAppActivationDependencies { + readonly getTarget: () => DesktopAppActivationTarget | null; + readonly findProject: ( + environmentId: EnvironmentId, + workspaceRoot: string, + ) => DesktopAppActivationProject | null; + readonly createProject: ( + environmentId: EnvironmentId, + workspaceRoot: string, + ) => Promise; + readonly waitForProject: (projectRef: ScopedProjectRef) => Promise; + readonly openThread: ( + projectRef: ScopedProjectRef, + ) => Promise<{ readonly threadId: ThreadId } | null>; +} + +function failure( + requestId: string, + code: DesktopAppActivationFailure["code"], + message: string, +): DesktopAppActivationFailure { + return { version: 1, requestId, ok: false, code, message }; +} + +export function desktopPlatformToEnvironmentOs( + platform: DesktopAppActivationRequest["platform"], +): ExecutionEnvironmentPlatformOs { + return platform === "win32" ? "windows" : platform; +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback; +} + +export async function handleDesktopAppActivationRequest( + request: DesktopAppActivationRequest, + dependencies: DesktopAppActivationDependencies, +): Promise { + const target = dependencies.getTarget(); + if (target === null) { + return failure( + request.requestId, + "environment-unavailable", + "The desktop app's primary local environment is not connected.", + ); + } + + const requestPlatform = desktopPlatformToEnvironmentOs(request.platform); + if (requestPlatform !== target.platform) { + return failure( + request.requestId, + "platform-mismatch", + `The command path is for ${requestPlatform}, but the desktop app's primary environment uses ${target.platform}. Cross-platform path mapping is not supported.`, + ); + } + + let projectId = dependencies.findProject(target.environmentId, request.workspaceRoot)?.id ?? null; + if (projectId === null) { + try { + projectId = await dependencies.createProject(target.environmentId, request.workspaceRoot); + await dependencies.waitForProject({ environmentId: target.environmentId, projectId }); + } catch (error) { + return failure( + request.requestId, + "project-create-failed", + errorMessage(error, "T3 Code could not add the project."), + ); + } + } + + try { + const opened = await dependencies.openThread({ + environmentId: target.environmentId, + projectId, + }); + if (opened === null) { + return failure( + request.requestId, + "thread-open-failed", + "T3 Code could not open a new thread for the project.", + ); + } + return { + version: 1, + requestId: request.requestId, + ok: true, + projectId, + threadId: opened.threadId, + }; + } catch (error) { + return failure( + request.requestId, + "thread-open-failed", + errorMessage(error, "T3 Code could not open a new thread for the project."), + ); + } +} diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index e1021a7fe..48017ac29 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -95,12 +95,7 @@ export function resolveInitialPrimaryEnvironmentDescriptor(): Promise - `[${count} earlier message(s) omitted to stay within input limits.]`; - -function messageRoleLabel(message: ChatMessage): "USER" | "ASSISTANT" { - return message.role === "assistant" ? "ASSISTANT" : "USER"; -} - -function attachmentSummary(message: ChatMessage): string | null { - const imageAttachments = message.attachments?.filter((attachment) => attachment.type === "image"); - const count = imageAttachments?.length ?? 0; - if (count === 0) { - return null; - } - - const names = imageAttachments?.slice(0, 3).map((image) => image.name) ?? []; - const namesSummary = names.join(", "); - const extraCount = count - names.length; - const extraSummary = extraCount > 0 ? ` (+${extraCount} more)` : ""; - return `[Attached image${count === 1 ? "" : "s"}: ${namesSummary}${extraSummary}]`; -} - -function buildMessageBlock(message: ChatMessage): string { - const text = message.text; - const attachments = attachmentSummary(message); - - if (text && attachments) { - return `${messageRoleLabel(message)}:\n${text}\n${attachments}`; - } - if (text) { - return `${messageRoleLabel(message)}:\n${text}`; - } - if (attachments) { - return `${messageRoleLabel(message)}:\n${attachments}`; - } - return `${messageRoleLabel(message)}:\n(empty message)`; -} - -function finalizeWithPrompt( - transcriptBody: string, - latestPrompt: string, - maxChars: number, -): string | null { - const text = `${BOOTSTRAP_PREAMBLE}\n\n${TRANSCRIPT_HEADER}\n${transcriptBody}\n\n${LATEST_PROMPT_HEADER}\n${latestPrompt}`; - return text.length <= maxChars ? text : null; -} - -export function buildBootstrapInput( - previousMessages: ChatMessage[], - latestPrompt: string, - maxChars: number, -): BootstrapInputResult { - const budget = Number.isFinite(maxChars) ? Math.max(1, Math.floor(maxChars)) : 1; - const promptOnly = latestPrompt.length <= budget ? latestPrompt : latestPrompt.slice(0, budget); - - if (previousMessages.length === 0) { - return { - text: promptOnly, - includedCount: 0, - omittedCount: 0, - truncated: promptOnly.length !== latestPrompt.length, - }; - } - - const newestFirstBlocks: string[] = []; - for (let index = previousMessages.length - 1; index >= 0; index -= 1) { - const message = previousMessages[index]; - if (!message) continue; - newestFirstBlocks.push(buildMessageBlock(message)); - } - - if (newestFirstBlocks.length === 0) { - return { - text: promptOnly, - includedCount: 0, - omittedCount: previousMessages.length, - truncated: true, - }; - } - - // Include a contiguous suffix from newest to oldest, then reverse to chronological. - let includedNewestFirst: string[] = []; - for (const block of newestFirstBlocks) { - const nextNewestFirst = [...includedNewestFirst, block]; - const nextChronological = nextNewestFirst.toReversed(); - const omittedCount = newestFirstBlocks.length - nextChronological.length; - const transcriptBody = - omittedCount > 0 - ? `${OMITTED_SUMMARY(omittedCount)}\n\n${nextChronological.join("\n\n")}` - : nextChronological.join("\n\n"); - if (!finalizeWithPrompt(transcriptBody, latestPrompt, budget)) { - break; - } - includedNewestFirst = nextNewestFirst; - } - - let includedChronological = includedNewestFirst.toReversed(); - while (true) { - const omittedCount = newestFirstBlocks.length - includedChronological.length; - const transcriptBody = - omittedCount > 0 - ? includedChronological.length > 0 - ? `${OMITTED_SUMMARY(omittedCount)}\n\n${includedChronological.join("\n\n")}` - : OMITTED_SUMMARY(omittedCount) - : includedChronological.join("\n\n"); - const finalized = finalizeWithPrompt(transcriptBody, latestPrompt, budget); - if (finalized) { - return { - text: finalized, - includedCount: includedChronological.length, - omittedCount, - truncated: omittedCount > 0 || latestPrompt.length !== promptOnly.length, - }; - } - - if (includedChronological.length === 0) { - return { - text: promptOnly, - includedCount: 0, - omittedCount: previousMessages.length, - truncated: true, - }; - } - - includedChronological = includedChronological.slice(1); - } -} diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts new file mode 100644 index 000000000..91b757f51 --- /dev/null +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => { + let completeProjectFileRead: (value: null) => void = () => undefined; + let projectFileRead = Promise.resolve(null); + let storedDraft: { + readonly draftId: string; + readonly environmentId: string; + readonly promotedTo: null; + readonly threadId: string; + } | null = null; + const router = { + state: { + location: { href: "/" }, + matches: [{ params: {} }], + }, + navigate: vi.fn(async (request: { readonly params: { readonly draftId: string } }) => { + router.state.location.href = `/draft/${request.params.draftId}`; + }), + }; + const draftStore = { + getComposerDraft: vi.fn(() => ({})), + getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft), + getDraftSession: vi.fn(() => null), + getDraftThread: vi.fn(() => null), + applyStickyState: vi.fn(), + setDraftThreadContext: vi.fn(), + setLogicalProjectDraftThreadId: vi.fn(), + setModelSelection: vi.fn(), + }; + + return { + completeProjectFileRead: (value: null) => completeProjectFileRead(value), + draftStore, + get projectFileRead() { + return projectFileRead; + }, + reset(nextStoredDraft: typeof storedDraft) { + storedDraft = nextStoredDraft; + router.state.location.href = "/"; + router.navigate.mockClear(); + draftStore.setLogicalProjectDraftThreadId.mockClear(); + projectFileRead = new Promise((resolve) => { + completeProjectFileRead = resolve; + }); + }, + router, + }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => ({ defaultThreadEnvMode: "local", newWorktreesStartFromOrigin: false }), +})); +vi.mock("@t3tools/client-runtime/environment", () => ({ + scopedProjectKey: () => "remote-project", + scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), + scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), +})); +vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default" })); +vi.mock("@t3tools/shared/threadEnvMode", () => ({ + resolveDefaultThreadEnvMode: (input: { + readonly projectFile: "local" | "worktree" | null; + readonly globalDefault: "local" | "worktree"; + }) => input.projectFile ?? input.globalDefault, +})); +vi.mock("@tanstack/react-router", () => ({ + useParams: () => null, + useRouter: () => testState.router, +})); +vi.mock("react", () => ({ + useCallback: (callback: T) => callback, + useMemo: (factory: () => T) => factory(), +})); +vi.mock("../components/Sidebar.logic", () => ({ orderItemsByPreferredIds: () => [] })); +vi.mock("../composerDraftStore", () => { + const useComposerDraftStore = Object.assign(() => null, { + getState: () => testState.draftStore, + }); + return { + composerDraftHasUserContent: () => false, + markPromotedDraftThreadByRef: vi.fn(), + useComposerDraftStore, + }; +}); +vi.mock("../lib/chatThreadActions", () => ({ + hasExplicitComposerModelSelection: () => false, + resolveNewDraftStartFromOrigin: () => false, + resolveNewThreadModelSelectionOverride: () => null, +})); +vi.mock("../lib/t3ProjectFileDefaults", () => ({ + readT3ProjectFileDefaultThreadEnvMode: () => testState.projectFileRead, +})); +vi.mock("../lib/utils", () => ({ + newDraftId: () => "draft-delayed", + newThreadId: () => "thread-delayed", +})); +vi.mock("../logicalProject", () => ({ + deriveLogicalProjectKeyFromSettings: () => "remote-project", + getProjectOrderKey: () => "remote-project", + selectProjectGroupingSettings: () => ({}), +})); +vi.mock("../state/entities", () => ({ + readProjects: () => [ + { + id: "project-remote", + environmentId: "environment-ssh", + workspaceRoot: "/remote/project", + defaultThreadEnvMode: null, + defaultModelSelection: null, + }, + ], + readThreadShell: () => null, + useProjects: () => [], + useThread: () => null, +})); +vi.mock("../state/server", () => ({ primaryServerSettingsAtom: {} })); +vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null })); +vi.mock("../uiStateStore", () => ({ + legacyProjectCwdPreferenceKey: () => "remote-project", + useUiStateStore: () => [], +})); +vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) })); + +import { useNewThreadHandler } from "./useHandleNewThread"; + +describe("useNewThreadHandler", () => { + it.each([ + ["new", null], + [ + "reusable", + { + draftId: "draft-existing", + environmentId: "environment-ssh", + promotedTo: null, + threadId: "thread-existing", + }, + ], + ])("abandons a delayed %s draft open when the user navigates elsewhere", async (_, draft) => { + testState.reset(draft); + const openThread = useNewThreadHandler(); + const pendingOpen = openThread( + { environmentId: "environment-ssh", projectId: "project-remote" } as never, + { replace: true }, + ); + + testState.router.state.location.href = "/usage"; + testState.completeProjectFileRead(null); + await pendingOpen; + + expect(testState.router.state.location.href).toBe("/usage"); + expect(testState.router.navigate).not.toHaveBeenCalled(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 438cda84c..c26b25d13 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -23,7 +23,7 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; -import { readThreadShell, useProjects, useThread } from "../state/entities"; +import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities"; import { hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, @@ -55,7 +55,6 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef } export function useNewThreadHandler() { - const projects = useProjects(); // New-thread defaults are a user preference, and the settings UI only ever // edits the primary environment's settings.json. Reading the target // environment's own settings here would silently reset remote projects to @@ -83,6 +82,7 @@ export function useNewThreadHandler() { // prepared checkout, a task to write — addresses that one rather than looking the project // up again and finding whichever draft it happens to hold. ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { + const projects = readProjects(); const { getComposerDraft, getDraftSessionByLogicalProjectKey, @@ -93,6 +93,8 @@ export function useNewThreadHandler() { setLogicalProjectDraftThreadId, setModelSelection, } = useComposerDraftStore.getState(); + const requestingRouteHref = router.state.location.href; + const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref; const currentRouteTarget = getCurrentRouteTarget(); // A new thread carries the user's working mode from the thread being // viewed. The target project's configured model still wins; runtime and @@ -219,6 +221,9 @@ export function useNewThreadHandler() { workspaceContext = pickExplicitWorkspaceOptions(options); } else if (!isDraftAlreadyOpen) { const defaultEnvMode = await resolveDefaultEnvMode(); + if (routeChangedSinceRequest()) { + return null; + } // The await yields. If the draft was opened (a concurrent // invocation's navigation landed), promoted to a real thread, // remapped away (a concurrent invocation registered a fresh @@ -355,6 +360,9 @@ export function useNewThreadHandler() { const createdAt = new Date().toISOString(); return (async () => { const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode()); + if (routeChangedSinceRequest()) { + return null; + } // The await yields, so a concurrent invocation may have registered a // draft for this logical project in the meantime. Registering ours // too would evict that draft while its navigation is in flight — @@ -421,7 +429,7 @@ export function useNewThreadHandler() { return { draftId, threadId }; })(); }, - [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, projects, router], + [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, router], ); } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 64d7e5de0..e43a9a490 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -387,19 +387,3 @@ export function useUpdateClientSettings() { }); }, []); } - -export function __resetClientSettingsPersistenceForTests(): void { - clientSettingsHydrationGeneration += 1; - clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; - clientSettingsHydrated = false; - clientSettingsHydrationPromise = null; - clientSettingsListeners.clear(); - clientSettingsHydrationListeners.clear(); -} - -export function __setClientSettingsForTests(settings: ClientSettings): void { - clientSettingsHydrationGeneration += 1; - clientSettingsSnapshot = settings; - clientSettingsHydrated = true; - clientSettingsHydrationPromise = null; -} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 20bc4c414..aca5e31f2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -210,7 +210,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --radius-xl: calc(var(--radius) + 4px); --radius-2xl: calc(var(--radius) + 8px); --radius-3xl: calc(var(--radius) + 12px); - --radius-4xl: calc(var(--radius) + 16px); @keyframes skeleton { /* Transform-only so the highlight sweep stays on the compositor, then a long hold with the band parked off-screen instead of a constant shimmer. */ @@ -1004,19 +1003,19 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil @variant dark { color-scheme: dark; - /* Keep the workspace in the same neutral-black family as sidebar v2. - Surfaces lift from this base instead of starting from a milky gray. */ + /* Keep controls and floating surfaces close to the neutral-black canvas. + Borders and hover states provide separation without milky gray fills. */ --background: var(--color-neutral-950); - --surface-raised: var(--secondary); + --surface-raised: color-mix(in srgb, var(--background) 97%, var(--color-white)); --foreground: var(--color-neutral-100); --card: color-mix(in srgb, var(--background) 97%, var(--color-white)); --card-foreground: var(--color-neutral-100); - --popover: color-mix(in srgb, var(--background) 94%, var(--color-white)); + --popover: color-mix(in srgb, var(--background) 97%, var(--color-white)); --popover-foreground: var(--color-neutral-100); --primary: oklch(0.571 0.21 264); - --secondary: --alpha(var(--color-white) / 4%); + --secondary: --alpha(var(--color-white) / 3%); --secondary-foreground: var(--color-neutral-100); - --muted: --alpha(var(--color-white) / 4%); + --muted: --alpha(var(--color-white) / 3%); --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); --accent: --alpha(var(--color-white) / 4%); --accent-foreground: var(--color-neutral-100); @@ -1652,8 +1651,8 @@ code { } .chat-markdown ul { - /* Reset for nested uls under a widened ol — --list-gutter is an inherited - custom property, so without this a task-list under a 3+ digit ordered + /* Reset for nested uls under a widened ol. --list-gutter is an inherited + custom property, so without this a task-list under a multi-digit ordered list would inherit the outer gutter instead of its own default. */ --list-gutter: 1.25rem; padding-left: 1.25rem; @@ -1662,7 +1661,7 @@ code { /* --list-gutter defaults to the same 1.25rem as .chat-markdown ul, but ChatMarkdown's `ol` renderer widens it (via inline style) for lists whose - widest marker is 3+ characters, so item 100+ isn't clipped by list-style-position: + widest marker has multiple characters so it isn't clipped by list-style-position: outside painting the marker past the padding box. Reset it here too so a nested ol without its own widened marker doesn't inherit the outer one. */ .chat-markdown ol { diff --git a/apps/web/src/lib/chunkReloadGuard.test.ts b/apps/web/src/lib/chunkReloadGuard.test.ts new file mode 100644 index 000000000..632e8ec8c --- /dev/null +++ b/apps/web/src/lib/chunkReloadGuard.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./chunkReloadGuard"; + +function createStorageStub(): Storage { + const store = new Map(); + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + removeItem: (key) => { + store.delete(key); + }, + clear: () => store.clear(), + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }; +} + +describe("reloadOnceForChunkLoadError", () => { + it("reloads on the first failure and lets the second one surface", () => { + const storage = createStorageStub(); + const reload = vi.fn(); + + expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(true); + expect(reload).toHaveBeenCalledTimes(1); + + expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(false); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("reloads again after a successful boot cleared the guard", () => { + const storage = createStorageStub(); + const reload = vi.fn(); + + reloadOnceForChunkLoadError(() => storage, reload); + clearChunkReloadGuard(() => storage); + + expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(true); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it("never reloads when storage is blocked, so a persistent failure cannot loop", () => { + const reload = vi.fn(); + const blocked = () => { + throw new DOMException("blocked", "SecurityError"); + }; + + expect(reloadOnceForChunkLoadError(blocked, reload)).toBe(false); + expect(reload).not.toHaveBeenCalled(); + expect(() => clearChunkReloadGuard(blocked)).not.toThrow(); + }); +}); diff --git a/apps/web/src/lib/chunkReloadGuard.ts b/apps/web/src/lib/chunkReloadGuard.ts new file mode 100644 index 000000000..c31127816 --- /dev/null +++ b/apps/web/src/lib/chunkReloadGuard.ts @@ -0,0 +1,39 @@ +// Split chunks are fetched lazily, so a deploy (or desktop server swap) +// between page load and a later fetch can 404 the old hashed assets. One +// reload picks up the fresh index.html. A sessionStorage flag keeps a +// persistent failure from becoming a reload loop, and a successful boot clears +// it so the next stale deploy gets its own single reload. +const CHUNK_RELOAD_GUARD_KEY = "t3code:chunk-load-reloaded"; + +/** + * Called from the `vite:preloadError` listener. Reloads at most once per + * failure streak and returns whether it did, so the caller knows whether to + * swallow the event or let the error surface through the normal paths. + */ +export function reloadOnceForChunkLoadError( + getStorage: () => Storage = () => window.sessionStorage, + reload: () => void = () => window.location.reload(), +): boolean { + let alreadyReloaded: boolean; + try { + const storage = getStorage(); + alreadyReloaded = storage.getItem(CHUNK_RELOAD_GUARD_KEY) === "1"; + if (!alreadyReloaded) storage.setItem(CHUNK_RELOAD_GUARD_KEY, "1"); + } catch { + // Without storage the guard cannot survive a reload, so a persistent + // failure would loop forever. Let the error surface instead. + return false; + } + if (alreadyReloaded) return false; + reload(); + return true; +} + +/** Clears the guard after a successful boot so a later stale deploy can reload again. */ +export function clearChunkReloadGuard(getStorage: () => Storage = () => window.sessionStorage) { + try { + getStorage().removeItem(CHUNK_RELOAD_GUARD_KEY); + } catch { + // Blocked storage never held the flag. + } +} diff --git a/apps/web/src/lib/diffRendering.test.ts b/apps/web/src/lib/diffRendering.test.ts index 9ffaaf27d..6ba83cc7a 100644 --- a/apps/web/src/lib/diffRendering.test.ts +++ b/apps/web/src/lib/diffRendering.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + buildFileDiffContentVersion, + buildFileDiffIdentityKey, buildFileDiffRenderKey, buildPatchCacheKey, getDiffLineStat, @@ -82,8 +84,8 @@ describe("getRenderablePatch", () => { }); }); -describe("buildFileDiffRenderKey", () => { - it("keeps file identity stable when Pierre hydrates a partial diff", () => { +describe("diff file reconciliation", () => { + it("keeps Pierre's render key stable when a partial diff hydrates", () => { const patch = [ "diff --git a/example.ts b/example.ts", "--- a/example.ts", @@ -104,6 +106,48 @@ describe("buildFileDiffRenderKey", () => { expect(buildFileDiffRenderKey(file)).toBe(key); }); + + it("keeps identities stable and versions local to the changed file", () => { + const patch = (secondLine: string) => + [ + "diff --git a/unchanged.ts b/unchanged.ts", + "--- a/unchanged.ts", + "+++ b/unchanged.ts", + "@@ -1 +1 @@", + "-before", + "+after", + "diff --git a/changed.ts b/changed.ts", + "--- a/changed.ts", + "+++ b/changed.ts", + "@@ -1 +1 @@", + "-old", + `+${secondLine}`, + ].join("\n"); + const before = getRenderablePatch(patch("new"), "before"); + const after = getRenderablePatch(patch("newer"), "after"); + expect(before?.kind).toBe("files"); + expect(after?.kind).toBe("files"); + if (before?.kind !== "files" || after?.kind !== "files") return; + + const [beforeUnchanged, beforeChanged] = before.files; + const [afterUnchanged, afterChanged] = after.files; + expect(beforeUnchanged).toBeDefined(); + expect(beforeChanged).toBeDefined(); + expect(afterUnchanged).toBeDefined(); + expect(afterChanged).toBeDefined(); + if (!beforeUnchanged || !beforeChanged || !afterUnchanged || !afterChanged) return; + + expect(buildFileDiffIdentityKey(afterUnchanged)).toBe( + buildFileDiffIdentityKey(beforeUnchanged), + ); + expect(buildFileDiffIdentityKey(afterChanged)).toBe(buildFileDiffIdentityKey(beforeChanged)); + expect(buildFileDiffContentVersion(afterUnchanged)).toBe( + buildFileDiffContentVersion(beforeUnchanged), + ); + expect(buildFileDiffContentVersion(afterChanged)).not.toBe( + buildFileDiffContentVersion(beforeChanged), + ); + }); }); describe("getDiffLineStat", () => { diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index c15569817..7d031e537 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -163,6 +163,10 @@ export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string return raw; } +export function buildFileDiffIdentityKey(fileDiff: FileDiffMetadata): string { + return `${resolveFileDiffPreviousPath(fileDiff)}\u0000${resolveFileDiffPath(fileDiff)}`; +} + export function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { const cacheKey = fileDiff.cacheKey; if (!cacheKey) return `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; @@ -170,6 +174,66 @@ export function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { return cacheKey.endsWith(":hydrated") ? cacheKey.slice(0, -":hydrated".length) : cacheKey; } +function hashFileDiffPart(hash: number, value: string | number | boolean | undefined): number { + const serialized = value === undefined ? "undefined" : String(value); + const withLength = fnv1a32(`${typeof value}:${serialized.length}:`, hash); + return fnv1a32(serialized, withLength); +} + +/** + * Content-only version for CodeView reconciliation. Pierre's cache key includes + * the whole patch, so using it here would repaint every file when one changes. + */ +export function buildFileDiffContentVersion(fileDiff: FileDiffMetadata): number { + let hash = FNV_OFFSET_BASIS_32; + const append = (value: string | number | boolean | undefined) => { + hash = hashFileDiffPart(hash, value); + }; + + append(fileDiff.name); + append(fileDiff.prevName); + append(fileDiff.lang); + append(fileDiff.newObjectId); + append(fileDiff.prevObjectId); + append(fileDiff.mode); + append(fileDiff.prevMode); + append(fileDiff.type); + append(fileDiff.isPartial); + append(fileDiff.splitLineCount); + append(fileDiff.unifiedLineCount); + + for (const line of fileDiff.additionLines) append(line); + for (const line of fileDiff.deletionLines) append(line); + for (const hunk of fileDiff.hunks) { + append(hunk.collapsedBefore); + append(hunk.additionStart); + append(hunk.additionCount); + append(hunk.additionLines); + append(hunk.additionLineIndex); + append(hunk.deletionStart); + append(hunk.deletionCount); + append(hunk.deletionLines); + append(hunk.deletionLineIndex); + append(hunk.hunkContext); + append(hunk.hunkSpecs); + append(hunk.splitLineStart); + append(hunk.splitLineCount); + append(hunk.unifiedLineStart); + append(hunk.unifiedLineCount); + append(hunk.noEOFCRAdditions); + append(hunk.noEOFCRDeletions); + for (const content of hunk.hunkContent) { + append(content.type); + append(content.additionLineIndex); + append(content.deletionLineIndex); + append(content.type === "change" ? content.additions : content.lines); + append(content.type === "change" ? content.deletions : undefined); + } + } + + return hash; +} + export function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string { switch (fileDiff.type) { case "new": diff --git a/apps/web/src/lib/syntaxHighlighting.ts b/apps/web/src/lib/syntaxHighlighting.ts index 171725617..3be008ac2 100644 --- a/apps/web/src/lib/syntaxHighlighting.ts +++ b/apps/web/src/lib/syntaxHighlighting.ts @@ -1,11 +1,20 @@ import { getSharedHighlighter, type DiffsHighlighter, + type HighlighterTypes, type SupportedLanguages, } from "@pierre/diffs"; import { resolveDiffThemeName } from "./diffRendering"; +/** + * Always highlight with the Oniguruma WASM engine — the JS regex engine can + * backtrack catastrophically and hang the tokenizing thread. The shared + * highlighter is a first-caller-wins singleton, so every creation site must + * pass this value. + */ +export const PREFERRED_HIGHLIGHTER: HighlighterTypes = "shiki-wasm"; + const highlighterPromiseCache = new Map>(); export function getSyntaxHighlighterPromise(language: string): Promise { @@ -15,7 +24,7 @@ export function getSyntaxHighlighterPromise(language: string): Promise { if (language === "text") { highlighterPromiseCache.delete(language); diff --git a/apps/web/src/lib/terminalUiStateCleanup.ts b/apps/web/src/lib/terminalUiStateCleanup.ts deleted file mode 100644 index 8535f29f1..000000000 --- a/apps/web/src/lib/terminalUiStateCleanup.ts +++ /dev/null @@ -1,33 +0,0 @@ -interface TerminalUiRetentionThread { - key: string; - deletedAt: string | null; - archivedAt: string | null; -} - -interface CollectActiveTerminalUiThreadKeysInput { - snapshotThreads: readonly TerminalUiRetentionThread[]; - draftThreadKeys: Iterable; -} - -export function collectActiveTerminalUiThreadKeys( - input: CollectActiveTerminalUiThreadKeysInput, -): Set { - const activeThreadKeys = new Set(); - const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.key, thread])); - for (const thread of input.snapshotThreads) { - if (thread.deletedAt !== null) continue; - if (thread.archivedAt !== null) continue; - activeThreadKeys.add(thread.key); - } - for (const draftThreadKey of input.draftThreadKeys) { - const snapshotThread = snapshotThreadById.get(draftThreadKey); - if ( - snapshotThread && - (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null) - ) { - continue; - } - activeThreadKeys.add(draftThreadKey); - } - return activeThreadKeys; -} diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 863388106..8f55f65e4 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -3,7 +3,6 @@ import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/c import { requestConfirmDialog } from "./confirmDialog"; import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback"; import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; -import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; let cachedApi: LocalApi | undefined; @@ -86,10 +85,3 @@ export function ensureLocalApi(): LocalApi { } return api; } - -export async function __resetLocalApiForTests() { - cachedApi = undefined; - const { __resetClientSettingsPersistenceForTests } = await import("./hooks/useSettings"); - __resetClientSettingsPersistenceForTests(); - resetRequestLatencyStateForTests(); -} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 8717b914e..cf6b1c07b 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,8 +1,5 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { ClerkProvider } from "@clerk/react"; -import { passkeys } from "@clerk/electron/passkeys"; -import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react"; import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; // Pylon ships its brand faces rather than inheriting whatever the OS supplies. @@ -15,7 +12,6 @@ import "@fontsource/jetbrains-mono/500.css"; import "./index.css"; import { isElectron } from "./env"; -import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; import { @@ -23,7 +19,7 @@ import { syncDocumentWindowControlsOverlayClass, } from "./lib/windowControlsOverlay"; import { AppRoot } from "./AppRoot"; -import { clerkAppearance } from "./components/clerk/clerkAppearance"; +import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); @@ -37,26 +33,59 @@ if (isElectron) { const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; +// A failed split-chunk fetch usually means the hashed assets went stale under +// a deploy; one guarded reload picks up the fresh index.html. +let chunkLoadFailed = false; +let reloadScheduled = false; +window.addEventListener("vite:preloadError", (event) => { + chunkLoadFailed = true; + if (reloadOnceForChunkLoadError()) { + reloadScheduled = true; + event.preventDefault(); + } +}); + const app = ; -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - {clerkPublishableKey && hasCloudPublicConfig() ? ( - isElectron ? ( - - {app} - - ) : ( - - {app} - - ) - ) : ( - app - )} - , -); +// Managed auth is cloud-only, and the Electron Clerk provider bundles the full +// clerk-js runtime. Loading only the selected runtime as a split chunk keeps +// every Clerk byte out of the startup graph for local-mode users, and keeps +// the bundled clerk-js out of the browser build entirely. +const managedAuthShellModule = + clerkPublishableKey && hasCloudPublicConfig() + ? isElectron + ? import("./components/clerk/ElectronManagedAuthShell") + : import("./components/clerk/BrowserManagedAuthShell") + : null; + +// The index.html boot splash lives inside #root, and React's first commit +// clears it. Resolve everything that first commit needs, the selected +// managed-auth runtime and the initial route's split chunks, before +// rendering, so the splash holds until real UI paints instead of dropping to +// a blank window while chunks download. +void Promise.all([managedAuthShellModule?.then((module) => module.default) ?? null, router.load()]) + .then(([ManagedAuthShell]) => { + // A route chunk failure still resolves router.load(): the error is parked in + // the lazy component and surfaces through the route error boundary. Skip the + // paint when a reload is on its way, and only re-arm the guard after a boot + // that fetched every chunk it asked for. + if (reloadScheduled) return; + if (!chunkLoadFailed) clearChunkReloadGuard(); + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + {ManagedAuthShell && clerkPublishableKey ? ( + {app} + ) : ( + app + )} + , + ); + }) + .catch((error: unknown) => { + // The auth shell chunk failed and the guarded reload is spent. Say so + // instead of leaving the splash up forever. + if (reloadScheduled) return; + console.error("T3 Code failed to load its startup chunks.", error); + const bootShell = document.getElementById("boot-shell"); + if (bootShell) bootShell.textContent = "T3 Code could not load. Reload to try again."; + }); diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index af2a2912c..4fc668928 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -33,6 +33,10 @@ class FakeElement { return this.childNodes.map((child) => child.textContent).join(""); } + get children(): ReadonlyArray { + return this.childNodes.filter((child): child is FakeElement => child instanceof FakeElement); + } + append(...children: Array): this { this.childNodes.push(...children); return this; @@ -45,6 +49,34 @@ class FakeElement { hasAttribute(name: string): boolean { return Object.hasOwn(this.attributes, name); } + + closest(): FakeElement | null { + return null; + } + + /** Supports only the selectors markdown-clipboard actually asks for. */ + querySelector(selector: string): FakeElement | null { + const childOnly = selector.startsWith(":scope > "); + const target = childOnly ? selector.slice(":scope > ".length) : selector; + const matches = (element: FakeElement): boolean => { + if (target === 'input[type="checkbox"]') { + return element.tagName === "INPUT" && element.getAttribute("type") === "checkbox"; + } + return element.tagName === target.toUpperCase(); + }; + const search = (parent: FakeElement): FakeElement | null => { + for (const child of parent.childNodes) { + if (!(child instanceof FakeElement)) continue; + if (matches(child)) return child; + if (!childOnly) { + const nested = search(child); + if (nested) return nested; + } + } + return null; + }; + return search(this); + } } function asNode(element: FakeElement): Node { @@ -56,6 +88,21 @@ function shikiCodeLine(text: string): FakeElement { return new FakeElement("SPAN", ["line"]).append(token); } +/** Mirrors a rendered code block: select-none header chrome plus a shiki pre. */ +function renderedCodeBlock(lines: ReadonlyArray): FakeElement { + const code = new FakeElement("CODE"); + lines.forEach((line, index) => { + if (index > 0) code.append(new FakeText("\n")); + code.append(shikiCodeLine(line)); + }); + return new FakeElement("DIV", ["chat-markdown-codeblock"]).append( + new FakeElement("DIV", ["chat-markdown-codeblock-header", "select-none"]).append( + new FakeText("sh"), + ), + new FakeElement("DIV", ["chat-markdown-shiki"]).append(new FakeElement("PRE").append(code)), + ); +} + describe("serializeRenderedMarkdownFragment", () => { beforeEach(() => { vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE }); @@ -94,6 +141,82 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); }); + it("keeps fences when a bare list item sits alongside the code block", () => { + // serializeListItem emits "- " for an item with no text, so the item is + // content the plain-code path would drop. + const container = new FakeElement("DIV").append( + new FakeElement("UL").append(new FakeElement("LI")), + renderedCodeBlock(["pnpm test"]), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("-\n\n```\npnpm test\n```"); + }); + + it("keeps fences when a checkbox-only task item sits alongside the code block", () => { + // The checkbox is a skipped tag, so the item renders no text of its own, but + // it still carries the task state. + const container = new FakeElement("DIV").append( + new FakeElement("UL").append( + new FakeElement("LI").append(new FakeElement("INPUT", [], { type: "checkbox" })), + ), + renderedCodeBlock(["pnpm test"]), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "- [ ]\n\n```\npnpm test\n```", + ); + }); + + it("still drops fences for a code block that is the whole list item", () => { + // The item only wraps the block, so a selection that never left the pre + // would drop the marker too. + const container = new FakeElement("DIV").append( + new FakeElement("UL").append(new FakeElement("LI").append(renderedCodeBlock(["pnpm test"]))), + new FakeText("\n"), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("pnpm test"); + }); + + it("keeps fences when a file chip sits alongside the code block", () => { + // The chip renders as a button, a skipped tag, but its data-markdown-copy + // still contributes markdown, so the block is not the only visible content. + const container = new FakeElement("DIV").append( + renderedCodeBlock(["pnpm test"]), + new FakeText("\n"), + new FakeElement("BUTTON", [], { "data-markdown-copy": "`src/foo.ts`" }), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "```\npnpm test\n```\n\n`src/foo.ts`", + ); + }); + + it("omits fences when a selection past the last line drags in the whole code block", () => { + // Dragging over the final newline ends the range after the pre, so the + // fragment carries the block plus the empty head of the next paragraph. + const container = new FakeElement("DIV").append( + renderedCodeBlock(["printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN"]), + new FakeText("\n"), + new FakeElement("P").append(new FakeText("")), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN", + ); + }); + + it("still fences a code block copied alongside prose", () => { + const container = new FakeElement("DIV").append( + new FakeElement("P").append(new FakeText("Run this:")), + renderedCodeBlock(["gh workflow run Deploy --ref main"]), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "Run this:\n\n```\ngh workflow run Deploy --ref main\n```", + ); + }); + it("uses a rendered card's explicit Markdown copy representation", () => { const card = new FakeElement("DIV", [], { "data-markdown-copy": "Hello World (Document template)\n\n", diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 069d161a1..4a96c8b31 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -256,6 +256,73 @@ function serializeNode(node: Node): string { } } +/** + * Tracks whether a fragment carries exactly one code block and nothing else a + * reader would see. + */ +interface SoleCodeBlockScan { + pre: Element | null; + other: boolean; +} + +function scanForSoleCodeBlock(node: Node, scan: SoleCodeBlockScan): void { + for (const child of node.childNodes) { + if (scan.other) return; + if (child.nodeType === Node.TEXT_NODE) { + if ((child.textContent ?? "").trim().length > 0) scan.other = true; + continue; + } + if (child.nodeType !== Node.ELEMENT_NODE) continue; + const element = child as Element; + // Mirrors serializeNode's order: an element carrying markdown of its own + // still contributes it even when its tag is otherwise skipped, as a file + // chip rendered as a button does. + if (element.hasAttribute("data-markdown-details")) { + scan.other = true; + continue; + } + const markdownCopy = element.getAttribute("data-markdown-copy"); + if (markdownCopy !== null) { + if (markdownCopy.trim().length > 0) scan.other = true; + continue; + } + if (isSkippedElement(element)) continue; + if (element.tagName === "PRE") { + if (scan.pre) scan.other = true; + else scan.pre = element; + continue; + } + if (element.tagName === "IMG" || element.tagName === "HR") { + scan.other = true; + continue; + } + if (element.tagName === "LI") { + // serializeListItem emits a marker ("- ", "1. ", "[x] ") for every item, + // so an item that does not hold the block carries content of its own even + // when it renders no text. An item that wraps the block is just the + // structure around it, and a pre-only selection would drop the marker too. + const preBeforeItem = scan.pre; + scanForSoleCodeBlock(element, scan); + if (!scan.other && scan.pre === preBeforeItem) scan.other = true; + continue; + } + scanForSoleCodeBlock(element, scan); + } +} + +/** + * A drag that ends on a block's final newline pulls the closing `pre` into the + * range, so the fragment holds the whole block even though the user only + * highlighted code. Re-fencing that pastes stray backticks, so a fragment whose + * only visible content is one code block copies as plain code, matching a + * selection that never left the `pre`. + */ +function soleCodeBlock(container: Node): Element | null { + const scan: SoleCodeBlockScan = { pre: null, other: false }; + scanForSoleCodeBlock(container, scan); + return scan.other ? null : scan.pre; +} + /** Collapses serializer spacing artifacts without touching fenced code content. */ function tidyMarkdown(markdown: string): string { return markdown @@ -268,6 +335,8 @@ function tidyMarkdown(markdown: string): string { } export function serializeRenderedMarkdownFragment(container: Node): string { + const codeBlock = soleCodeBlock(container); + if (codeBlock) return (codeBlock.textContent ?? "").replace(/\n$/, ""); return tidyMarkdown(serializeChildren(container)); } diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index d9c3aa9ef..eee3537ff 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -234,6 +234,29 @@ describe("instance-scoped model selection", () => { ]); }); + it("drops server-reported custom rows that are no longer in settings", () => { + const baseProvider = provider({ + instanceId: "claude_openrouter", + models: ["claude-sonnet-4-6"], + }); + const providers = [ + { + ...baseProvider, + models: [ + ...baseProvider.models, + { slug: "removed/custom", name: "removed/custom", isCustom: true, capabilities: {} }, + ], + }, + ]; + const openrouter = deriveProviderInstanceEntries(providers)[0]!; + + expect( + getAppModelOptionsForInstance(settingsWithProviderInstances(), openrouter).map( + (option) => option.slug, + ), + ).toEqual(["claude-sonnet-4-6", "openai/gpt-5.5"]); + }); + it("applies persisted per-instance model ordering", () => { const providers = [ provider({ diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 2684c97f0..7770aa73c 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -180,7 +180,12 @@ export function getAppModelOptions( selectedModel?: string | null, ): AppModelOption[] { const rawModels = getProviderModels(providers, provider); - const options: AppModelOption[] = rawModels.map(toAppModelOption); + // Server-reported custom rows mirror settings and can lag a removal, so + // only built-ins are taken from the snapshot; custom rows are rebuilt from + // settings below. + const options: AppModelOption[] = rawModels + .filter((model) => !model.isCustom) + .map(toAppModelOption); const seen = new Set(options.map((option) => option.slug)); const builtInModelSlugs = new Set( Arr.filterMap(getProviderModels(providers, provider), (model) => @@ -226,14 +231,18 @@ export function getAppModelOptions( * when present, falling back to the legacy per-kind * `settings.providers[driverKind].customModels` bucket for default * instances only. This keeps two instances of the same kind from leaking - * custom slugs into each other. + * custom slugs into each other. Custom rows reported by the server are + * ignored so a slug removed in Settings disappears without waiting for the + * next provider probe. */ export function getAppModelOptionsForInstance( settings: UnifiedSettings, entry: ProviderInstanceEntry, selectedModel?: string | null, ): AppModelOption[] { - const options: AppModelOption[] = entry.models.map(toAppModelOption); + const options: AppModelOption[] = entry.models + .filter((model) => !model.isCustom) + .map(toAppModelOption); const seen = new Set(options.map((option) => option.slug)); const builtInModelSlugs = new Set( Arr.filterMap(entry.models, (model) => diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 2d07e218e..95d390b90 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -131,17 +131,3 @@ async function disposeTracerRuntime( await settleAsyncResult(() => runtime.runPromiseExit(Scope.close(scope, Exit.void))); runtime.dispose(); } - -export async function __resetClientTracingForTests() { - configurationGeneration++; - activeConfigKey = null; - activeDelegate = null; - pendingConfiguration = Promise.resolve(); - - const runtime = activeRuntime; - const scope = activeScope; - activeRuntime = null; - activeScope = null; - - await disposeTracerRuntime(runtime, scope); -} diff --git a/apps/web/src/orchestrationEventEffects.ts b/apps/web/src/orchestrationEventEffects.ts deleted file mode 100644 index 34e33ace8..000000000 --- a/apps/web/src/orchestrationEventEffects.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; - -export interface OrchestrationBatchEffects { - promoteDraftThreadIds: ThreadId[]; - clearDeletedThreadIds: ThreadId[]; - removeTerminalUiStateThreadIds: ThreadId[]; - needsProviderInvalidation: boolean; -} - -export function deriveOrchestrationBatchEffects( - events: readonly OrchestrationEvent[], -): OrchestrationBatchEffects { - const threadLifecycleEffects = new Map< - ThreadId, - { - clearPromotedDraft: boolean; - clearDeletedThread: boolean; - removeTerminalUiState: boolean; - } - >(); - let needsProviderInvalidation = false; - - for (const event of events) { - switch (event.type) { - case "thread.turn-diff-completed": - case "thread.reverted": { - needsProviderInvalidation = true; - break; - } - - case "thread.created": { - threadLifecycleEffects.set(event.payload.threadId, { - clearPromotedDraft: true, - clearDeletedThread: false, - removeTerminalUiState: false, - }); - break; - } - - case "thread.deleted": { - threadLifecycleEffects.set(event.payload.threadId, { - clearPromotedDraft: false, - clearDeletedThread: true, - removeTerminalUiState: true, - }); - break; - } - - case "thread.archived": { - threadLifecycleEffects.set(event.payload.threadId, { - clearPromotedDraft: false, - clearDeletedThread: false, - removeTerminalUiState: true, - }); - break; - } - - case "thread.unarchived": { - threadLifecycleEffects.set(event.payload.threadId, { - clearPromotedDraft: false, - clearDeletedThread: false, - removeTerminalUiState: false, - }); - break; - } - - default: { - break; - } - } - } - - const promoteDraftThreadIds: ThreadId[] = []; - const clearDeletedThreadIds: ThreadId[] = []; - const removeTerminalUiStateThreadIds: ThreadId[] = []; - for (const [threadId, effect] of threadLifecycleEffects) { - if (effect.clearPromotedDraft) { - promoteDraftThreadIds.push(threadId); - } - if (effect.clearDeletedThread) { - clearDeletedThreadIds.push(threadId); - } - if (effect.removeTerminalUiState) { - removeTerminalUiStateThreadIds.push(threadId); - } - } - - return { - promoteDraftThreadIds, - clearDeletedThreadIds, - removeTerminalUiStateThreadIds, - needsProviderInvalidation, - }; -} diff --git a/apps/web/src/orchestrationRecovery.ts b/apps/web/src/orchestrationRecovery.ts deleted file mode 100644 index c9ccf3a39..000000000 --- a/apps/web/src/orchestrationRecovery.ts +++ /dev/null @@ -1,211 +0,0 @@ -export type OrchestrationRecoveryReason = - | "bootstrap" - | "sequence-gap" - | "resubscribe" - | "replay-failed"; - -export interface OrchestrationRecoveryPhase { - kind: "snapshot" | "replay"; - reason: OrchestrationRecoveryReason; -} - -export interface OrchestrationRecoveryState { - latestSequence: number; - highestObservedSequence: number; - bootstrapped: boolean; - pendingReplay: boolean; - inFlight: OrchestrationRecoveryPhase | null; -} - -export interface ReplayRecoveryCompletion { - replayMadeProgress: boolean; - shouldReplay: boolean; -} - -export interface ReplayRetryTracker { - attempts: number; - latestSequence: number; - highestObservedSequence: number; -} - -export interface ReplayRetryDecision { - shouldRetry: boolean; - delayMs: number; - tracker: ReplayRetryTracker | null; -} - -type SequencedEvent = Readonly<{ sequence: number }>; - -export function deriveReplayRetryDecision(input: { - previousTracker: ReplayRetryTracker | null; - completion: ReplayRecoveryCompletion; - recoveryState: Pick; - baseDelayMs: number; - maxNoProgressRetries: number; -}): ReplayRetryDecision { - if (!input.completion.shouldReplay) { - return { - shouldRetry: false, - delayMs: 0, - tracker: null, - }; - } - - if (input.completion.replayMadeProgress) { - return { - shouldRetry: true, - delayMs: 0, - tracker: null, - }; - } - - const previousTracker = input.previousTracker; - const sameFrontier = - previousTracker !== null && - previousTracker.latestSequence === input.recoveryState.latestSequence && - previousTracker.highestObservedSequence === input.recoveryState.highestObservedSequence; - - const attempts = sameFrontier && previousTracker !== null ? previousTracker.attempts + 1 : 1; - if (attempts > input.maxNoProgressRetries) { - return { - shouldRetry: false, - delayMs: 0, - tracker: null, - }; - } - - return { - shouldRetry: true, - delayMs: input.baseDelayMs * 2 ** (attempts - 1), - tracker: { - attempts, - latestSequence: input.recoveryState.latestSequence, - highestObservedSequence: input.recoveryState.highestObservedSequence, - }, - }; -} - -export function createOrchestrationRecoveryCoordinator() { - let state: OrchestrationRecoveryState = { - latestSequence: 0, - highestObservedSequence: 0, - bootstrapped: false, - pendingReplay: false, - inFlight: null, - }; - let replayStartSequence: number | null = null; - - const snapshotState = (): OrchestrationRecoveryState => ({ - ...state, - ...(state.inFlight ? { inFlight: { ...state.inFlight } } : {}), - }); - - const observeSequence = (sequence: number) => { - state.highestObservedSequence = Math.max(state.highestObservedSequence, sequence); - }; - - const resolveReplayNeedAfterRecovery = () => { - const pendingReplayBeforeReset = state.pendingReplay; - const observedAhead = state.highestObservedSequence > state.latestSequence; - const shouldReplay = pendingReplayBeforeReset || observedAhead; - state.pendingReplay = false; - return { - shouldReplay, - pendingReplayBeforeReset, - observedAhead, - }; - }; - - return { - getState(): OrchestrationRecoveryState { - return snapshotState(); - }, - - classifyDomainEvent(sequence: number): "ignore" | "defer" | "recover" | "apply" { - observeSequence(sequence); - if (sequence <= state.latestSequence) { - return "ignore"; - } - if (!state.bootstrapped || state.inFlight) { - state.pendingReplay = true; - return "defer"; - } - if (sequence !== state.latestSequence + 1) { - state.pendingReplay = true; - return "recover"; - } - return "apply"; - }, - - markEventBatchApplied(events: ReadonlyArray): ReadonlyArray { - const nextEvents = events - .filter((event) => event.sequence > state.latestSequence) - .toSorted((left, right) => left.sequence - right.sequence); - if (nextEvents.length === 0) { - return []; - } - - state.latestSequence = nextEvents.at(-1)?.sequence ?? state.latestSequence; - state.highestObservedSequence = Math.max(state.highestObservedSequence, state.latestSequence); - return nextEvents; - }, - - beginSnapshotRecovery(reason: OrchestrationRecoveryReason): boolean { - if (state.inFlight?.kind === "snapshot") { - state.pendingReplay = true; - return false; - } - if (state.inFlight?.kind === "replay") { - state.pendingReplay = true; - return false; - } - state.inFlight = { kind: "snapshot", reason }; - return true; - }, - - completeSnapshotRecovery(snapshotSequence: number): boolean { - state.latestSequence = Math.max(state.latestSequence, snapshotSequence); - state.highestObservedSequence = Math.max(state.highestObservedSequence, state.latestSequence); - state.bootstrapped = true; - state.inFlight = null; - return resolveReplayNeedAfterRecovery().shouldReplay; - }, - - failSnapshotRecovery(): void { - state.inFlight = null; - }, - - beginReplayRecovery(reason: OrchestrationRecoveryReason): boolean { - if (!state.bootstrapped || state.inFlight?.kind === "snapshot") { - state.pendingReplay = true; - return false; - } - if (state.inFlight?.kind === "replay") { - state.pendingReplay = true; - return false; - } - state.pendingReplay = false; - replayStartSequence = state.latestSequence; - state.inFlight = { kind: "replay", reason }; - return true; - }, - - completeReplayRecovery(): ReplayRecoveryCompletion { - const replayMadeProgress = - replayStartSequence !== null && state.latestSequence > replayStartSequence; - replayStartSequence = null; - state.inFlight = null; - const replayResolution = resolveReplayNeedAfterRecovery(); - return { - replayMadeProgress, - shouldReplay: replayResolution.shouldReplay, - }; - }, - - failReplayRecovery(): void { - replayStartSequence = null; - state.bootstrapped = false; - state.inFlight = null; - }, - }; -} diff --git a/apps/web/src/providerSkillSearch.test.ts b/apps/web/src/providerSkillSearch.test.ts index 133f65218..01059d614 100644 --- a/apps/web/src/providerSkillSearch.test.ts +++ b/apps/web/src/providerSkillSearch.test.ts @@ -48,6 +48,19 @@ describe("searchProviderSkills", () => { expect(searchProviderSkills(skills, "gfc").map((skill) => skill.name)).toEqual(["gh-fix-ci"]); }); + it("keeps user-only skills and omits agent-only ones", () => { + const skills = [ + makeSkill({ name: "re-release-version", userInvocationOnly: true }), + makeSkill({ name: "release-context", userInvocable: false }), + makeSkill({ name: "release-version" }), + ]; + + expect(searchProviderSkills(skills, "release").map((skill) => skill.name)).toEqual([ + "release-version", + "re-release-version", + ]); + }); + it("omits disabled skills from results", () => { const skills = [ makeSkill({ name: "ui", displayName: "Ui", enabled: false }), diff --git a/apps/web/src/providerSkillSearch.ts b/apps/web/src/providerSkillSearch.ts index 964907365..21dd28744 100644 --- a/apps/web/src/providerSkillSearch.ts +++ b/apps/web/src/providerSkillSearch.ts @@ -2,6 +2,7 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; import { dedupeProviderSkillsByName, formatProviderSkillDisplayName, + isProviderSkillUserInvocable, } from "@t3tools/client-runtime/providerSkills"; import { insertRankedSearchResult, @@ -73,7 +74,7 @@ export function searchProviderSkills( query: string, limit = Number.POSITIVE_INFINITY, ): ServerProviderSkill[] { - const enabledSkills = dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled)); + const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable)); const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ }); if (!normalizedQuery) { diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index c29f8d738..7f23d4088 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -130,10 +130,6 @@ const REMOTE_FALLBACK_EDITORS: ReadonlyArray = ["vscode"]; let cachedProbedEditors: ReadonlyArray | null = null; -export function __resetRemoteEditorProbeForTests(): void { - cachedProbedEditors = null; -} - export function useRemoteCapableEditors(): ReadonlyArray { const [editors, setEditors] = useState>( () => cachedProbedEditors ?? REMOTE_FALLBACK_EDITORS, diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 86ba9d69a..4362dd3d7 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -7,6 +7,10 @@ export function getRouter(history: RouterHistory) { routeTree, history, context: {}, + // Route components are split chunks (autoCodeSplitting in vite.config); + // fetching them on hover/focus intent hides the load from the first + // settings or pull-request navigation. + defaultPreload: "intent", }); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 4476616a1..64e858d18 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -8,9 +8,10 @@ import { useLocation, useNavigate, } from "@tanstack/react-router"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding"; +import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; @@ -18,9 +19,11 @@ import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; +import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme"; import { useEnvironmentThemeSync } from "../hooks/useEnvironmentTheme"; import { Button } from "../components/ui/button"; @@ -139,6 +142,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} @@ -265,7 +269,9 @@ function HostedStaticEnvironmentBootstrap() { function RootRouteErrorView({ error, reset }: ErrorComponentProps) { const message = errorMessage(error); - const details = errorDetails(error); + // Router pathname rather than window.location: desktop uses hash history, where the window path is always "/". + const pathname = useLocation({ select: (location) => location.pathname }); + const report = useMemo(() => errorReport(error, pathname), [error, pathname]); return (
@@ -290,22 +296,32 @@ function RootRouteErrorView({ error, reset }: ErrorComponentProps) { +
-
- - Show error details - Hide error details - -
-            {details}
+        
+

Error report

+
+            {report}
           
-
+ ); } +/** Copies the full error report and swaps to a check mark for a moment as confirmation. */ +function CopyErrorButton({ report }: { report: string }) { + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "error-report" }); + + return ( + + ); +} + function errorMessage(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; @@ -334,6 +350,29 @@ function errorDetails(error: unknown): string { } } +const MAX_ERROR_CAUSE_DEPTH = 5; + +/** + * Full error text for bug reports: app build, page path, time, then the stack + * and any cause chain. Takes the pathname only so tokens in the query never + * land on the clipboard. + */ +function errorReport(error: unknown, pathname: string): string { + const lines = [ + `${APP_DISPLAY_NAME} ${APP_VERSION}`, + `Path: ${pathname}`, + `Time: ${new Date().toISOString()}`, + "", + errorDetails(error), + ]; + let cause = error instanceof Error ? error.cause : undefined; + for (let depth = 0; cause !== undefined && depth < MAX_ERROR_CAUSE_DEPTH; depth += 1) { + lines.push("", "Caused by:", errorDetails(cause)); + cause = cause instanceof Error ? cause.cause : undefined; + } + return lines.join("\n"); +} + function AuthenticatedTracingBootstrap() { useEffect(() => { void configureClientTracing(); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 9bb626cd2..f216fcbe3 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -760,7 +760,16 @@ function PullRequestsRouteView() { // Built together so the two reads share one memo, and in the same field order the feed's own // input uses: the atoms are keyed by their input, so the Authored tab then reads this answer. const partitionTargets = useMemo(() => { - if (!partitionsWanted) return { authored: NO_LIST_TARGETS, reviewing: NO_LIST_TARGETS }; + // The main list goes first. Besides putting the visible rows on screen sooner, it proves + // which repositories the host search indexes, so an empty partition does not trigger the + // expensive per-repository fallback. With no rows at all both partitions are already empty. + if ( + !partitionsWanted || + baselineQuery.data === null || + baselineQuery.data.entries.length === 0 + ) { + return { authored: NO_LIST_TARGETS, reviewing: NO_LIST_TARGETS }; + } const targetsFor = (involvement: PullRequestInvolvement) => environmentQueries.map(({ environmentId, projectIds }) => ({ environmentId, @@ -779,6 +788,7 @@ function PullRequestsRouteView() { menuFiltered, menuFilters, partitionsWanted, + baselineQuery.data, environmentQueries, scopedProjectId, search.host, diff --git a/apps/web/src/rpc/atomRegistry.ts b/apps/web/src/rpc/atomRegistry.ts index 3fb12914a..c9ac7ef82 100644 --- a/apps/web/src/rpc/atomRegistry.ts +++ b/apps/web/src/rpc/atomRegistry.ts @@ -2,13 +2,8 @@ import { RegistryContext } from "@effect/atom-react"; import { AtomRegistry } from "effect/unstable/reactivity"; import { createElement } from "react"; -export let appAtomRegistry = AtomRegistry.make(); +export const appAtomRegistry = AtomRegistry.make(); export function AppAtomRegistryProvider({ children }: React.PropsWithChildren) { return createElement(RegistryContext.Provider, { value: appAtomRegistry }, children); } - -export function resetAppAtomRegistryForTests() { - appAtomRegistry.dispose(); - appAtomRegistry = AtomRegistry.make(); -} diff --git a/apps/web/src/rpc/requestLatencyState.test.ts b/apps/web/src/rpc/requestLatencyState.test.ts index 68433035f..1cfae8a6f 100644 --- a/apps/web/src/rpc/requestLatencyState.test.ts +++ b/apps/web/src/rpc/requestLatencyState.test.ts @@ -59,6 +59,13 @@ describe("requestLatencyState", () => { expect(getSlowRpcAckRequests()).toEqual([]); }); + it("ignores usage summary requests", () => { + trackRpcRequestSent("1", WS_METHODS.serverGetUsageSummary); + vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); + + expect(getSlowRpcAckRequests()).toEqual([]); + }); + it.each(Object.values(WS_METHODS).filter((method) => method.startsWith("pullRequests.")))( "ignores pull request workspace request %s", (method) => { diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index 4ec5b56f9..9015a3c40 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -28,7 +28,10 @@ interface PendingRpcAckRequest { } const pendingRpcAckRequests = new Map(); -const untrackedRpcAckMethods = new Set([WS_METHODS.previewAutomationConnect]); +const untrackedRpcAckMethods = new Set([ + WS_METHODS.previewAutomationConnect, + WS_METHODS.serverGetUsageSummary, +]); const longRunningRpcAckMethods = new Set([ WS_METHODS.serverUpdateProvider, WS_METHODS.serverRefreshProviders, @@ -153,10 +156,6 @@ export function resetRequestLatencyStateForTests(): void { clearAllTrackedRpcRequests(); } -export function setSlowRpcAckThresholdMsForTests(thresholdMs: number): void { - slowRpcAckThresholdMs = thresholdMs; -} - export function useSlowRpcAckRequests(): ReadonlyArray { return useAtomValue(slowRpcAckRequestsAtom); } diff --git a/apps/web/src/state/desktopSshHosts.test.ts b/apps/web/src/state/desktopSshHosts.test.ts index 83eda6015..39958ac6e 100644 --- a/apps/web/src/state/desktopSshHosts.test.ts +++ b/apps/web/src/state/desktopSshHosts.test.ts @@ -4,7 +4,7 @@ import { AtomRegistry } from "effect/unstable/reactivity"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { describe, expect, it, vi } from "vite-plus/test"; -import { createDesktopSshHostsStateAtom } from "./desktopSshHosts"; +import { createDesktopSshHostsStateAtom, filterDiscoveredSshHosts } from "./desktopSshHosts"; const hosts: ReadonlyArray = [ { @@ -16,6 +16,86 @@ const hosts: ReadonlyArray = [ }, ]; +describe("filterDiscoveredSshHosts", () => { + const suggestions: ReadonlyArray = [ + { + alias: "grape", + hostname: "grape", + port: null, + source: "known-hosts", + username: null, + }, + { + alias: "Pinot", + hostname: "Pinot", + port: null, + source: "ssh-config", + username: null, + }, + { + alias: "preprod", + hostname: "preprod", + port: 2222, + source: "ssh-config", + username: "deploy", + }, + { + alias: "prod-z", + hostname: "prod-z", + port: null, + source: "ssh-config", + username: null, + }, + { + alias: "prod-a", + hostname: "prod-a", + port: null, + source: "ssh-config", + username: null, + }, + { + alias: "prod-m", + hostname: "prod-m", + port: null, + source: "ssh-config", + username: null, + }, + ]; + + it.each(["", " "])("returns every host for an empty query %j", (query) => { + expect(filterDiscoveredSshHosts(suggestions, query)).toEqual(suggestions); + }); + + it("trims the query", () => { + expect(filterDiscoveredSshHosts(suggestions, " pi ")).toEqual([suggestions[1]]); + }); + + it("ranks alias prefix matches before substring matches", () => { + expect(filterDiscoveredSshHosts(suggestions, "prod")).toEqual([ + suggestions[3], + suggestions[4], + suggestions[5], + suggestions[2], + ]); + }); + + it("preserves the original order within a match tier", () => { + expect(filterDiscoveredSshHosts(suggestions, "prod").slice(0, 3)).toEqual([ + suggestions[3], + suggestions[4], + suggestions[5], + ]); + }); + + it("matches case-insensitively", () => { + expect(filterDiscoveredSshHosts(suggestions, "PINOT")).toEqual([suggestions[1]]); + }); + + it("returns an empty array when no hosts match", () => { + expect(filterDiscoveredSshHosts(suggestions, "merlot")).toEqual([]); + }); +}); + describe("desktopSshHostsState", () => { it("retains discovered hosts when the settings screen remounts", async () => { const discoverSshHosts = vi.fn(async () => hosts); diff --git a/apps/web/src/state/desktopSshHosts.ts b/apps/web/src/state/desktopSshHosts.ts index 8e4022cbe..027ce8676 100644 --- a/apps/web/src/state/desktopSshHosts.ts +++ b/apps/web/src/state/desktopSshHosts.ts @@ -5,6 +5,28 @@ import { Atom } from "effect/unstable/reactivity"; type DesktopSshDiscoveryBridge = Pick; +/** Filters and ranks SSH host suggestions as the user types in the host field. */ +export function filterDiscoveredSshHosts( + hosts: ReadonlyArray, + query: string, +): ReadonlyArray { + const normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.length === 0) return hosts; + + const prefixMatches: Array = []; + const substringMatches: Array = []; + for (const host of hosts) { + const alias = host.alias.toLowerCase(); + if (alias.startsWith(normalizedQuery)) { + prefixMatches.push(host); + } else if (alias.includes(normalizedQuery)) { + substringMatches.push(host); + } + } + + return [...prefixMatches, ...substringMatches]; +} + class DesktopSshDiscoveryUnavailableError extends Schema.TaggedErrorClass()( "DesktopSshDiscoveryUnavailableError", {}, diff --git a/apps/web/src/state/desktopUpdate.test.ts b/apps/web/src/state/desktopUpdate.test.ts index 0c05b2d45..f3e7818ed 100644 --- a/apps/web/src/state/desktopUpdate.test.ts +++ b/apps/web/src/state/desktopUpdate.test.ts @@ -16,6 +16,7 @@ const baseState: DesktopUpdateState = { availableVersion: null, downloadedVersion: null, releaseNotes: [], + omittedReleaseCount: 0, downloadPercent: null, checkedAt: null, message: null, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 7bca31182..ec1e4c836 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -8,16 +8,8 @@ import { type EnvironmentThreadStatus, mergeEnvironmentThread, } from "@t3tools/client-runtime/state/threads"; -import type { - OrchestrationMessage, - OrchestrationProposedPlan, - OrchestrationSession, - OrchestrationThreadActivity, - ScopedProjectRef, - ScopedThreadRef, - ServerConfig, -} from "@t3tools/contracts"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { ScopedProjectRef, ScopedThreadRef, ServerConfig } from "@t3tools/contracts"; +import type { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { useMemo } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -26,18 +18,11 @@ import { environmentServerConfigsAtom } from "./server"; import { allEnvironmentShellsBootstrappedAtom } from "./shell"; import { environmentThreadDetails, environmentThreadShells } from "./threads"; -const EMPTY_PROJECT_REFS: ReadonlyArray = Object.freeze([]); const EMPTY_THREAD_REFS: ReadonlyArray = Object.freeze([]); -const EMPTY_MESSAGES: ReadonlyArray = Object.freeze([]); -const EMPTY_ACTIVITIES: ReadonlyArray = Object.freeze([]); -const EMPTY_PROPOSED_PLANS: ReadonlyArray = Object.freeze([]); const EMPTY_PROJECT_ATOM = Atom.make(null).pipe( Atom.withLabel("web-project:empty"), ); -const EMPTY_PROJECT_REFS_ATOM = Atom.make(EMPTY_PROJECT_REFS).pipe( - Atom.withLabel("web-project-refs:empty"), -); const EMPTY_THREAD_REFS_ATOM = Atom.make(EMPTY_THREAD_REFS).pipe( Atom.withLabel("web-thread-refs:empty"), ); @@ -50,18 +35,6 @@ const EMPTY_THREAD_DETAIL_ATOM = Atom.make(null).pipe( const EMPTY_THREAD_STATUS_ATOM = Atom.make("empty").pipe( Atom.withLabel("web-thread-status:empty"), ); -const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( - Atom.withLabel("web-thread-messages:empty"), -); -const EMPTY_ACTIVITIES_ATOM = Atom.make(EMPTY_ACTIVITIES).pipe( - Atom.withLabel("web-thread-activities:empty"), -); -const EMPTY_PROPOSED_PLANS_ATOM = Atom.make(EMPTY_PROPOSED_PLANS).pipe( - Atom.withLabel("web-thread-proposed-plans:empty"), -); -const EMPTY_SESSION_ATOM = Atom.make(null).pipe( - Atom.withLabel("web-thread-session:empty"), -); export const activeEnvironmentIdAtom = Atom.make(null).pipe( Atom.keepAlive, @@ -72,32 +45,14 @@ export function useActiveEnvironmentId(): EnvironmentId | null { return useAtomValue(activeEnvironmentIdAtom); } -export function readActiveEnvironmentId(): EnvironmentId | null { - return appAtomRegistry.get(activeEnvironmentIdAtom); -} - export function setActiveEnvironmentId(environmentId: EnvironmentId | null): void { appAtomRegistry.set(activeEnvironmentIdAtom, environmentId); } -export function useProjectRefs(): ReadonlyArray { - return useAtomValue(environmentProjects.projectRefsAtom); -} - export function useThreadRefs(): ReadonlyArray { return useAtomValue(environmentThreadShells.threadRefsAtom); } -export function useEnvironmentProjectRefs( - environmentId: EnvironmentId | null, -): ReadonlyArray { - return useAtomValue( - environmentId === null - ? EMPTY_PROJECT_REFS_ATOM - : environmentProjects.environmentProjectRefsAtom(environmentId), - ); -} - export function useEnvironmentThreadRefs( environmentId: EnvironmentId | null, ): ReadonlyArray { @@ -184,38 +139,37 @@ export function useThread( return useMemo(() => mergeEnvironmentThread(detail, shell), [detail, shell]); } -export function useThreadMessages( - ref: ScopedThreadRef | null, -): ReadonlyArray { - return useAtomValue( - ref === null ? EMPTY_MESSAGES_ATOM : environmentThreadDetails.messagesAtom(ref), - ); -} - -export function useThreadActivities( - ref: ScopedThreadRef | null, -): ReadonlyArray { - return useAtomValue( - ref === null ? EMPTY_ACTIVITIES_ATOM : environmentThreadDetails.activitiesAtom(ref), - ); +export function readProject(ref: ScopedProjectRef): EnvironmentProject | null { + return appAtomRegistry.get(environmentProjects.projectAtom(ref)); } -export function useThreadProposedPlans( - ref: ScopedThreadRef | null, -): ReadonlyArray { - return useAtomValue( - ref === null ? EMPTY_PROPOSED_PLANS_ATOM : environmentThreadDetails.proposedPlansAtom(ref), - ); +export function readProjects(): ReadonlyArray { + return appAtomRegistry.get(environmentProjects.projectsAtom); } -export function useThreadSession(ref: ScopedThreadRef | null): OrchestrationSession | null { - return useAtomValue( - ref === null ? EMPTY_SESSION_ATOM : environmentThreadDetails.sessionAtom(ref), - ); -} +/** Resolves when the project event reaches the live client store. */ +export function waitForProject( + ref: ScopedProjectRef, + timeoutMs = 10_000, +): Promise { + const current = readProject(ref); + if (current !== null) return Promise.resolve(current); -export function readProject(ref: ScopedProjectRef): EnvironmentProject | null { - return appAtomRegistry.get(environmentProjects.projectAtom(ref)); + return new Promise((resolve, reject) => { + let unsubscribe: (() => void) | null = null; + const timeout = setTimeout(() => { + unsubscribe?.(); + reject(new Error("The project did not appear in the desktop app.")); + }, timeoutMs); + const finish = (project: EnvironmentProject | null) => { + if (project === null) return; + clearTimeout(timeout); + unsubscribe?.(); + resolve(project); + }; + unsubscribe = appAtomRegistry.subscribe(environmentProjects.projectAtom(ref), finish); + finish(readProject(ref)); + }); } export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null { @@ -268,28 +222,12 @@ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): ); } -export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { - return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); -} - export function readEnvironmentThreadRefs( environmentId: EnvironmentId, ): ReadonlyArray { return appAtomRegistry.get(environmentThreadShells.environmentThreadRefsAtom(environmentId)); } -export function readThreadRefs(): ReadonlyArray { - return appAtomRegistry.get(environmentThreadShells.threadRefsAtom); -} - export function readThreadShells(): ReadonlyArray { return appAtomRegistry.get(environmentThreadShells.threadShellsAtom); } - -export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null { - return ( - appAtomRegistry - .get(environmentThreadShells.threadRefsAtom) - .find((ref) => ref.threadId === threadId) ?? null - ); -} diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 939c4f3ae..601b6efa3 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { - createLinkedPullRequestDetailAtomFamily, + createLinkedPullRequestSummaryAtomFamily, createPullRequestEnvironmentAtoms, } from "@t3tools/client-runtime/state/pull-requests"; import type { @@ -23,7 +23,7 @@ import { formatEnvironmentQueryError } from "./query"; export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime); export const linkedPullRequestDetailAtom = - createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime); + createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); export interface EnvironmentQueryTarget { readonly environmentId: EnvironmentId; diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index f34a7e484..16a145000 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -11,6 +11,7 @@ import { getThemePreferenceMode, isKnownThemePreference, getCustomThemes, + getStandardThemeColors, getStoredCustomThemeCollection, invalidateCustomThemes, installCustomTheme, @@ -135,6 +136,19 @@ describe("theme files", () => { expect(asHex(dark.error)).not.toBe(asHex(darkDefaults.error)); }); + it("keeps stock dark controls in the neutral-black surface hierarchy", () => { + expectThemeColors(getStandardThemeColors("dark"), { + canvas: "#0a0a0a", + surface: "#111111", + surfaceRaised: "#111111", + surfaceOverlay: "#111111", + toolbarControl: "#111111", + secondary: "#111111", + muted: "#111111", + accentSurface: "#141414", + }); + }); + it("derives readable, distinctive vivid palettes from exact seeds", () => { const seeds: ReadonlyArray<["light" | "dark", string, string]> = [ ["light", "#f4f9f2", "#1d8a4e"], @@ -157,6 +171,9 @@ describe("theme files", () => { expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.textMuted, colors.canvas)).toBeLessThan(5.5); expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeLessThan( + contrastRatio(colors.text, colors.muted), + ); expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeLessThan( contrastRatio(colors.text, colors.surfaceRaised), diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index d845261b5..343e9229b 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -406,12 +406,12 @@ const T3_CODE_DARK_THEME_COLORS: ThemeColors = { toolbar: "#0a0a0a", toolbarForeground: "#f5f5f5", toolbarBorder: "#191919", - toolbarControl: "#191919", + toolbarControl: "#111111", toolbarControlForeground: "#f5f5f5", toolbarControlHover: "#141414", surface: "#111111", - surfaceRaised: "#141414", - surfaceOverlay: "#191919", + surfaceRaised: "#111111", + surfaceOverlay: "#111111", text: "#f5f5f5", textMuted: "#818181", border: "#191919", @@ -419,9 +419,9 @@ const T3_CODE_DARK_THEME_COLORS: ThemeColors = { focus: "#346bf1", accent: "#346bf1", accentForeground: "#ffffff", - secondary: "#141414", + secondary: "#111111", secondaryForeground: "#f5f5f5", - muted: "#141414", + muted: "#111111", mutedForeground: "#818181", placeholder: "#818181", secondaryLabel: "#818181", @@ -920,7 +920,7 @@ export function createVividThemeColors( themeOklchToThemeColor( solveOklchLightness(textBase, surfaceRgb, 4.6, dark ? "lighter" : "darker"), ); - const mutedForeground = foregroundOn(mutedRgb); + const mutedForeground = themeRgbToThemeColor(readableThemeText(mutedRgb, textRgb, 1, 4.6)); const placeholder = themeRgbToThemeColor(readableThemeText(surfaceRaisedRgb, textRgb, 1, 4.6)); const actionHover: ThemeOklch = { ...action, L: action.L + (dark ? 0.06 : -0.06) }; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 032fe8dec..d2b4e9c4f 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -168,7 +168,10 @@ export default defineConfig(() => { assetsInclude: ["**/*.wasm"], plugins: [ devCompressionPlugin(), - tanstackRouter(), + // Route components load as split chunks so settings, pull-request, and + // usage code stay out of the cold-start payload; the router prefetches + // them on navigation intent (see getRouter's defaultPreload). + tanstackRouter({ autoCodeSplitting: true }), react(), babel({ // We need to be explicit about the parser options after moving to @vitejs/plugin-react v6.0.0 @@ -268,6 +271,11 @@ export default defineConfig(() => { } : {}), }, + // @tailwindcss/vite only emits a CSS sourcemap when devSourcemap is on; without it + // rolldown flags the transform as SOURCEMAP_BROKEN on every sourcemapped build. + css: { + devSourcemap: buildSourcemap !== false, + }, build: { outDir: "dist", emptyOutDir: true, diff --git a/docs/user/composer.md b/docs/user/composer.md index cb8c819f2..ac4e82cfa 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -73,7 +73,8 @@ path** and **Open in file viewer**. These actions are available in expanded prev On mobile, touch and hold an inline image or use a preview's **Media actions** menu to see its source, copy the path or URL, or choose **Save or share**. Workspace media can open in the file viewer from the same menu. Saving downloads a copy only when you request it; it does not change -how the video buffers during playback. +how the video buffers during playback. On iOS, touch and hold a file reference in a message to +copy its full or relative path or open it in the file viewer. Use Markdown image syntax to embed either kind of media: @@ -150,6 +151,15 @@ slash menu** in **Settings → General** in the web or desktop app. Skill result name remains searchable. If the provider also reports that skill as a native slash command, Pylon hides the duplicate native entry and keeps the `/skill:Skill Name` label. +A skill token runs the skill wherever it sits in your message. T3 Code sends it to each provider in +the form that provider runs, so the text before and after the token is kept. Skills that only you may +start, and never the agent on its own, work the same way. A skill you switched off in the provider's +settings does not appear in either menu. + +Provider commands such as `/compact` only run when they open the message, so the `/` menu offers +them only there. T3 Code's own commands, such as `/model` and `/plan`, and skills stay available on +any line. + On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from a new thread to start it in the background. Pylon opens another new thread and shows an **Open** action for the thread that started. The new thread keeps the selected workspace mode and base branch. If **New diff --git a/docs/user/install.md b/docs/user/install.md index e3af0a85f..c98a7ff39 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -17,6 +17,26 @@ npx t3@latest This starts the Pylon server on your machine and opens the local web app. Use `npx t3@latest --help` for the full CLI reference. +## Open a project in the desktop app + +When the T3 Code desktop app is running on the same machine, open the current directory with: + +```bash +npx t3 app +``` + +Pass a path to open another directory: + +```bash +npx t3 app ../my-project +``` + +The command adds the directory as a project when needed, focuses the desktop app, and opens a new +thread. It does not launch the desktop app, open a browser, or start a T3 Code server. A background +server does not count as the desktop app. The command also rejects SSH sessions because a remote +shell cannot focus a local desktop window. The CLI package and the running desktop app must both +include `t3 app` support. + ## Desktop App ### Pylon fork diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 4d7527242..1a9f22c5f 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -17,8 +17,8 @@ without prompting; commands and anything else still stop for approval. **Auto**: routine actions proceed without you; risky ones still ask. How this is enforced depends on the provider: Codex delegates routine approvals to an AI reviewer, Claude uses its own auto -permission mode, and providers without an equivalent (such as OpenCode) fall back to asking, like -Supervised. +permission mode, Cursor uses Smart Auto review, and providers without an equivalent (such as +OpenCode) fall back to asking, like Supervised. **Full access**: allow commands and edits without prompts. The default. The agent runs unattended until it finishes or asks a question of its own. diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index b8d95a3d6..5bd3214a3 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -48,10 +48,17 @@ Claude can show its own resume prompt when you continue an old session. ## Where Claude Skills Are Loaded -Pylon looks for Claude skills in the Claude config directory's `skills` folder, then -`/.agents/skills`, then `/.claude/skills`. +Pylon looks for Claude skills in the Claude config directory's `skills` folder and +`/.claude/skills`, the two places Claude Code loads them from. -If the same skill name exists in more than one folder, the later folder wins. +If the same skill name exists in more than one folder, the one in the Claude config directory +wins, the same way Claude Code resolves it. + +A skill set to `off` in Claude Code's `skillOverrides` is left out of both composer menus. A skill +marked `disable-model-invocation` still appears, because you start it yourself when you pick it. +Claude Code runs one skill per message; when a message names several, the last one runs directly and +Claude starts the others through its Skill tool, which refuses skills marked +`disable-model-invocation`. ## I Want Work And Personal Claude Accounts diff --git a/docs/user/updating.md b/docs/user/updating.md index 178bfb690..f4b94473c 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -57,6 +57,16 @@ the warning always works. See [Running Pylon in the Background](./background-service.md) for install, status, and removal commands. +## Nightly desktop release notes + +The desktop app shows a compact release-notes preview when a nightly update is available. Changes +appear newest first within each release. Each release links to its exact page on GitHub, even when +all changes fit in the preview. + +The preview shows up to eight changes from each of six releases. When it leaves out changes or older +releases, it shows the exact number and links to the rest. Contributor credits do not count as +changes. + ## After the Update Keep the web or desktop app open while the server restarts. The update completes only after the diff --git a/package.json b/package.json index 74fd6b240..753f040e9 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test", "fmt": "vp fmt", "fmt:check": "vp fmt --check", - "build:contracts": "vp run --filter @t3tools/contracts build", "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64", @@ -42,7 +41,6 @@ "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64", "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", - "connect:announce-ga": "node scripts/announce-connect-ga.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .vite-plus apps/*/.vite-plus packages/*/.vite-plus", "sync:repos": "node scripts/sync-reference-repos.ts" }, @@ -58,10 +56,5 @@ "engines": { "node": "^24.13.1" }, - "packageManager": "pnpm@11.10.0", - "msw": { - "workerDirectory": [ - "apps/web/public" - ] - } + "packageManager": "pnpm@11.10.0" } diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index 0d3849878..efe4da03b 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -26,7 +26,10 @@ import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as HttpClient from "effect/unstable/http/HttpClient"; -import type { PreparedHttpAuthorization } from "../connection/model.ts"; +import { + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS, + type PreparedHttpAuthorization, +} from "../connection/model.ts"; export interface RelayEnvironmentAuthorization { readonly environmentId: EnvironmentId; @@ -61,7 +64,6 @@ export class RemoteEnvironmentAuthorization extends Context.Service< } >()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} -const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000; const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000; const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000; @@ -208,7 +210,7 @@ export const make = Effect.gen(function* () { Option.isSome(cached) && cached.value.environmentId === input.expectedEnvironmentId && cached.value.dpopThumbprint === thumbprint && - cached.value.expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS + cached.value.expiresAtEpochMs > now + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS ) { yield* Effect.annotateCurrentSpan({ "connection.remote_token_cache": "hit", @@ -226,6 +228,7 @@ export const make = Effect.gen(function* () { httpAuthorization: { _tag: "Dpop" as const, accessToken: cached.value.accessToken, + expiresAtEpochMs: cached.value.expiresAtEpochMs, }, }; } @@ -297,6 +300,7 @@ export const make = Effect.gen(function* () { httpAuthorization: { _tag: "Dpop" as const, accessToken: token.accessToken, + expiresAtEpochMs: token.expiresAtEpochMs, }, }; }, diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index 8df3b2541..a79307947 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -104,12 +104,6 @@ export const PlatformConnectionRegistration = Schema.Union([ ]); export type PlatformConnectionRegistration = typeof PlatformConnectionRegistration.Type; -export function connectionRegistrationTarget( - registration: ConnectionRegistration | PrimaryConnectionRegistration, -): ConnectionTarget { - return registration.target; -} - export function connectionRegistrationCatalogEntry( registration: ConnectionRegistration | PrimaryConnectionRegistration, ): ConnectionCatalogEntry { diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts index fbcb302ed..e67e8bdf7 100644 --- a/packages/client-runtime/src/connection/model.ts +++ b/packages/client-runtime/src/connection/model.ts @@ -103,6 +103,8 @@ export class ConnectionBlockedError extends Schema.TaggedErrorClass { httpAuthorization: { _tag: "Dpop" as const, accessToken: "dpop-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, }, }), ), @@ -387,6 +389,7 @@ describe("ConnectionResolver", () => { httpAuthorization: { _tag: "Dpop" as const, accessToken: "dpop-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, }, }), Effect.withSpan("test.remote.authorizeDpop"), diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 5e50c44d9..39019e89c 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -16,6 +16,7 @@ import type { ConnectionCatalogEntry } from "./catalog.ts"; import * as Connectivity from "./connectivity.ts"; import * as ConnectionDriver from "./driver.ts"; import { + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS, ConnectionBlockedError, ConnectionTransientError, PrimaryConnectionTarget, @@ -1095,6 +1096,43 @@ describe("EnvironmentSupervisor", () => { }), ); + it.effect("renews a relay connection before its DPoP access token expires", () => + Effect.gen(function* () { + const tokenLifetimeMs = DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS * 2; + const harness = yield* makeHarness({ + prepare: (attempt) => + Effect.succeed({ + ...PREPARED_CONNECTION, + target: RELAY_TARGET, + httpAuthorization: { + _tag: "Dpop", + accessToken: `access-token-${attempt}`, + expiresAtEpochMs: tokenLifetimeMs * attempt, + }, + }), + }); + const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* TestClock.adjust(DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS - 1); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + + yield* TestClock.adjust(1); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect( + Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).httpAuthorization, + ).toMatchObject({ accessToken: "access-token-2" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("interrupts relay setup when credentials change", () => Effect.gen(function* () { const firstAttemptStarted = yield* Deferred.make(); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 85fda10ef..a2dafa3ca 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -18,6 +18,7 @@ import type { ConnectionCatalogEntry } from "./catalog.ts"; import * as Connectivity from "./connectivity.ts"; import * as ConnectionDriver from "./driver.ts"; import { + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS, type ConnectionAttemptError, type ConnectionTarget, ConnectionTransientError, @@ -487,6 +488,21 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } }); + const waitForAuthorizationRefresh = Effect.fnUntraced(function* ( + preparedConnection: PreparedConnection, + ) { + const authorization = preparedConnection.httpAuthorization; + if (authorization?._tag !== "Dpop") { + return yield* Effect.never; + } + const now = yield* Clock.currentTimeMillis; + yield* Effect.sleep( + Math.max(0, authorization.expiresAtEpochMs - now - DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS), + ); + yield* Effect.logDebug("Refreshing the environment connection before its DPoP token expires."); + return true; + }); + const runAttempt = Effect.fnUntraced(function* ( attempt: number, generation: number, @@ -584,7 +600,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( retryAt: null, }); - const connectedExit = yield* Effect.raceFirst( + const connectedExit = yield* Effect.raceAllFirst([ active.lease.session.closed.pipe( Effect.mapError( (error): TracedAttemptFailure => ({ @@ -601,7 +617,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( }), ), ), - ).pipe(exitUnlessInterrupted); + waitForAuthorizationRefresh(active.lease.prepared), + ]).pipe(exitUnlessInterrupted); const connectedForMs = (yield* Clock.currentTimeMillis) - connectedAt; if (Exit.isSuccess(connectedExit)) { return { diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 42ceefacb..2793e1d38 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -123,6 +123,30 @@ describe("getProviderSkillsForSlashMenu", () => { }); }); +describe("getProviderSkillsForSlashMenu", () => { + it("drops a skill the provider reserves for the agent", () => { + const skills = [ + { + name: "legacy-system-context", + path: "/Users/matt/.claude/skills/legacy-system-context/SKILL.md", + enabled: true, + userInvocable: false, + }, + { + name: "deploy", + path: "/Users/matt/.claude/skills/deploy/SKILL.md", + enabled: true, + // Reserved for the user, not the agent: still a valid pick. + userInvocationOnly: true, + }, + ]; + + expect(getProviderSkillsForSlashMenu(skills, true).map((skill) => skill.name)).toEqual([ + "deploy", + ]); + }); +}); + describe("getProviderSlashCommandsForSlashMenu", () => { const commands = [ { name: "ask-matt", description: "Ask which skill fits your situation." }, diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 653c1dd85..f90ffe142 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -39,12 +39,25 @@ export function dedupeProviderSkillsByName( }); } +/** + * Whether a composer pick can start this skill. A skill switched off in the + * provider's settings will not run, and one the provider reserves for the + * agent (Claude Code's `user-invocable: false`) rejects a user invocation. + * Everything else, including skills the agent may not start on its own, is + * fair game: the server dispatches the pick in the provider's native form. + */ +export function isProviderSkillUserInvocable( + skill: Pick, +): boolean { + return skill.enabled && skill.userInvocable !== false; +} + export function getProviderSkillsForSlashMenu( skills: ReadonlyArray, showSkillsInSlashMenu: boolean, ): ServerProviderSkill[] { return showSkillsInSlashMenu - ? dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled)) + ? dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable)) : []; } diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index b601b59bf..9f4c83609 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -2,10 +2,8 @@ import type { EnvironmentId, OrchestrationMessage, OrchestrationProjectShell, - OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadShell, - ThreadId, } from "@t3tools/contracts"; export interface EnvironmentProject extends OrchestrationProjectShell { @@ -42,12 +40,3 @@ export function scopeThread( ): EnvironmentThread { return { ...thread, environmentId }; } - -export function selectEnvironmentThreadShell( - snapshot: OrchestrationShellSnapshot | null, - environmentId: EnvironmentId, - threadId: ThreadId, -): EnvironmentThreadShell | null { - const thread = snapshot?.threads.find((candidate) => candidate.id === threadId) ?? null; - return thread ? scopeThreadShell(environmentId, thread) : null; -} diff --git a/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts b/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts index 837bd2d5d..1553bb34d 100644 --- a/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts +++ b/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts @@ -5,7 +5,10 @@ import * as Option from "effect/Option"; import { PrimaryConnectionTarget, type PreparedConnection } from "../connection/model.ts"; import { remoteHttpClientLayer } from "../rpc/http.ts"; -import { fetchEnvironmentPullRequestDiff } from "./pullRequestDiffHttp.ts"; +import { + fetchEnvironmentPullRequestDiff, + PullRequestDiffCredentialRejectedError, +} from "./pullRequestDiffHttp.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -77,4 +80,41 @@ describe("fetchEnvironmentPullRequestDiff", () => { }); }), ); + + it.effect("gives rejected diff sessions a recovery action", () => + Effect.gen(function* () { + const fetchFn = (() => + Promise.resolve( + Response.json( + { + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-auth-test", + }, + { status: 401 }, + ), + )) satisfies typeof fetch; + + const error = yield* fetchEnvironmentPullRequestDiff({ + prepared: PREPARED, + signer: Option.none(), + diff: { + projectId: ProjectId.make("project-1"), + repository: "owner/repository", + number: 42, + }, + }).pipe(Effect.provide(remoteHttpClientLayer(fetchFn)), Effect.flip); + + expect(error).toBeInstanceOf(PullRequestDiffCredentialRejectedError); + expect(error).toMatchObject({ + repository: "owner/repository", + number: 42, + traceId: "trace-auth-test", + }); + expect(error.message).toBe( + "This environment session is no longer valid (invalid_credential). Refresh the page or quit and reopen T3 Code.", + ); + }), + ); }); diff --git a/packages/client-runtime/src/state/pullRequestDiffHttp.ts b/packages/client-runtime/src/state/pullRequestDiffHttp.ts index 48304663c..b6b925917 100644 --- a/packages/client-runtime/src/state/pullRequestDiffHttp.ts +++ b/packages/client-runtime/src/state/pullRequestDiffHttp.ts @@ -1,8 +1,13 @@ -import type { PullRequestDiffInput, PullRequestDiffResult } from "@t3tools/contracts"; +import { + EnvironmentAuthInvalidError, + type PullRequestDiffInput, + type PullRequestDiffResult, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import type { PreparedConnection } from "../connection/model.ts"; @@ -17,6 +22,24 @@ import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./envir const DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS = 60_000; +export class PullRequestDiffCredentialRejectedError extends Schema.TaggedErrorClass()( + "PullRequestDiffCredentialRejectedError", + { + repository: Schema.String, + number: Schema.Number, + traceId: Schema.String, + cause: EnvironmentAuthInvalidError, + }, +) { + override get message(): string { + return "This environment session is no longer valid (invalid_credential). Refresh the page or quit and reopen T3 Code."; + } +} + +export type PullRequestDiffLoadError = + | RemoteEnvironmentRequestError + | PullRequestDiffCredentialRejectedError; + export const fetchEnvironmentPullRequestDiff = Effect.fn( "clientRuntime.state.fetchEnvironmentPullRequestDiff", )(function* (input: { @@ -42,6 +65,17 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn( input.prepared.httpAuthorization, client.pullRequests.diff({ payload: input.diff, headers }), ), + ).pipe( + Effect.mapError((error) => + error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential" + ? new PullRequestDiffCredentialRejectedError({ + repository: input.diff.repository, + number: input.diff.number, + traceId: error.traceId, + cause: error, + }) + : error, + ), ); }); @@ -51,7 +85,7 @@ export class PullRequestDiffLoader extends Context.Service< readonly load: ( prepared: PreparedConnection, input: PullRequestDiffInput, - ) => Effect.Effect; + ) => Effect.Effect; } >()("@t3tools/client-runtime/state/pullRequestDiffHttp/PullRequestDiffLoader") {} diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 22d44336a..b98524e04 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -2,6 +2,7 @@ import { WS_METHODS, type PullRequestDetail, type PullRequestDiffInput, + type PullRequestSummary, type VcsStatusResult, } from "@t3tools/contracts"; import * as Data from "effect/Data"; @@ -20,26 +21,34 @@ import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; -export { PullRequestDiffLoader, pullRequestDiffLoaderLayer } from "./pullRequestDiffHttp.ts"; +export { + type PullRequestDiffLoadError, + PullRequestDiffCredentialRejectedError, + PullRequestDiffLoader, + pullRequestDiffLoaderLayer, +} from "./pullRequestDiffHttp.ts"; export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( "EnvironmentHttpConnectionNotReadyError", )<{ readonly message: string }> {} -/** Refresh a linked PR while its thread is visible so merges update the sidebar. */ -export function createLinkedPullRequestDetailAtomFamily( +export const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000; + +/** Refresh only the live fields a linked thread renders. */ +export function createLinkedPullRequestSummaryAtomFamily( runtime: Atom.AtomRuntime, ) { return createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:pull-requests:linked-detail", - tag: WS_METHODS.pullRequestsDetail, - staleTimeMs: 15_000, - refreshIntervalMs: 30_000, + label: "environment-data:pull-requests:linked-summary", + tag: WS_METHODS.pullRequestsSummary, + staleTimeMs: 60_000, + refreshIntervalMs: 60_000, + idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS, }); } export function pullRequestDetailToVcsStatus( - detail: PullRequestDetail, + detail: PullRequestDetail | PullRequestSummary, ): NonNullable { return { number: detail.number, diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 80aaa14a2..4b0fc3308 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -13,12 +13,10 @@ import { type EnvironmentRpcInput, type EnvironmentRpcStreamFailure, type EnvironmentRpcStreamValue, - type EnvironmentStreamCommandRpcTag, type EnvironmentSubscriptionRpcTag, type EnvironmentUnaryRpcTag, EnvironmentRpcUnavailableError, request, - runStream, subscribe, } from "../rpc/client.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; @@ -609,29 +607,6 @@ export function createEnvironmentCommand( }); } -function createEnvironmentStreamCommand( - runtime: Atom.AtomRuntime, - options: { - readonly label: string; - readonly execute: (input: Input) => Stream.Stream; - readonly scheduler?: AtomCommandScheduler; - readonly concurrency?: AtomCommandConcurrency<{ - readonly environmentId: EnvironmentIdType; - readonly input: Input; - }>; - }, -) { - return createRuntimeStreamCommand(runtime, { - label: options.label, - ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), - ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), - execute: (target) => - runStreamInEnvironment(target.environmentId, options.execute(target.input)).pipe( - Stream.withSpan(options.label), - ), - }); -} - export function createEnvironmentRpcQueryAtomFamily( runtime: Atom.AtomRuntime, options: { @@ -727,27 +702,3 @@ export function createEnvironmentRpcCommand( - runtime: Atom.AtomRuntime, - options: { - readonly label: string; - readonly tag: TTag; - readonly scheduler?: AtomCommandScheduler; - readonly concurrency?: AtomCommandConcurrency<{ - readonly environmentId: EnvironmentIdType; - readonly input: EnvironmentRpcInput; - }>; - }, -) { - return createEnvironmentStreamCommand(runtime, { - label: options.label, - ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), - ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), - execute: (input: EnvironmentRpcInput) => runStream(options.tag, input), - }); -} diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 39bc18d9f..34d40e87f 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -331,14 +331,6 @@ export function applyServerConfigProjection( } } -export function projectServerConfig( - current: Option.Option, - event: ServerConfigStreamEvent, -): readonly [Option.Option, ReadonlyArray] { - const next = applyServerConfigProjection(current, event); - return [next, Option.toArray(next)]; -} - const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEvent => ({ version: 1, type: "snapshot", diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 922774a16..ebb13a7d7 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -497,6 +497,139 @@ describe("applyThreadDetailEvent", () => { expect(result.thread.latestTurn?.completedAt).toBeNull(); } }); + + it("keeps latestTurn and checkpoints references across a streaming delta", () => { + const streamingThread: OrchestrationThread = { + ...baseThread, + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "claude", + runtimeMode: "full-access", + activeTurnId: TurnId.make("turn-1"), + lastError: null, + updatedAt: "2026-04-01T06:59:00.000Z", + }, + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "running", + requestedAt: "2026-04-01T06:59:00.000Z", + startedAt: "2026-04-01T06:59:00.000Z", + completedAt: null, + assistantMessageId: MessageId.make("msg-2"), + }, + messages: [ + { + id: MessageId.make("msg-2"), + role: "assistant", + text: "Hello", + turnId: TurnId.make("turn-1"), + streaming: true, + createdAt: "2026-04-01T06:00:00.000Z", + updatedAt: "2026-04-01T06:00:00.000Z", + }, + ], + checkpoints: [ + { + turnId: TurnId.make("turn-1"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("ref-1"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("msg-2"), + completedAt: "2026-04-01T06:00:30.000Z", + }, + ], + }; + + const result = applyThreadDetailEvent(streamingThread, { + ...baseEventFields, + sequence: 9, + occurredAt: "2026-04-01T07:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.message-sent", + payload: { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("msg-2"), + role: "assistant", + text: ", world", + turnId: TurnId.make("turn-1"), + streaming: true, + createdAt: "2026-04-01T06:00:00.000Z", + updatedAt: "2026-04-01T07:00:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages).not.toBe(streamingThread.messages); + expect(result.thread.messages[0]?.text).toBe("Hello, world"); + expect(result.thread.latestTurn).toBe(streamingThread.latestTurn); + expect(result.thread.checkpoints).toBe(streamingThread.checkpoints); + } + }); + + it("replaces latestTurn and checkpoints when the first assistant message binds the turn", () => { + const unboundThread: OrchestrationThread = { + ...baseThread, + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "claude", + runtimeMode: "full-access", + activeTurnId: TurnId.make("turn-1"), + lastError: null, + updatedAt: "2026-04-01T06:59:00.000Z", + }, + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "running", + requestedAt: "2026-04-01T06:59:00.000Z", + startedAt: "2026-04-01T06:59:00.000Z", + completedAt: null, + assistantMessageId: null, + }, + checkpoints: [ + { + turnId: TurnId.make("turn-1"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("ref-1"), + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-04-01T06:59:30.000Z", + }, + ], + }; + + const result = applyThreadDetailEvent(unboundThread, { + ...baseEventFields, + sequence: 9, + occurredAt: "2026-04-01T07:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.message-sent", + payload: { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("msg-2"), + role: "assistant", + text: "Hello", + turnId: TurnId.make("turn-1"), + streaming: true, + createdAt: "2026-04-01T07:00:00.000Z", + updatedAt: "2026-04-01T07:00:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.latestTurn).not.toBe(unboundThread.latestTurn); + expect(result.thread.latestTurn?.assistantMessageId).toBe("msg-2"); + expect(result.thread.checkpoints).not.toBe(unboundThread.checkpoints); + expect(result.thread.checkpoints[0]?.assistantMessageId).toBe("msg-2"); + } + }); }); describe("thread.session-set", () => { @@ -728,6 +861,184 @@ describe("applyThreadDetailEvent", () => { } }); + it("re-sorts when an activity arrives out of order", () => { + const makeActivity = (id: string, sequence: number) => ({ + id: EventId.make(id), + tone: "tool" as const, + kind: "command", + summary: `Ran command ${sequence}`, + payload: {}, + turnId: TurnId.make("turn-1"), + sequence, + createdAt: "2026-04-01T11:00:00.000Z", + }); + const result = applyThreadDetailEvent( + { + ...baseThread, + activities: [makeActivity("activity-a", 1), makeActivity("activity-c", 3)], + }, + { + ...baseEventFields, + sequence: 131, + occurredAt: "2026-04-01T11:01:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.activity-appended", + payload: { + threadId: ThreadId.make("thread-1"), + activity: makeActivity("activity-b", 2), + }, + }, + ); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.activities.map((activity) => activity.id)).toEqual([ + "activity-a", + "activity-b", + "activity-c", + ]); + } + }); + + it("repairs snapshot ordering before fast-path appends engage", () => { + const makeActivity = (id: string, sequence: number | null) => ({ + id: EventId.make(id), + tone: "tool" as const, + kind: "command", + summary: `Ran ${id}`, + payload: {}, + turnId: TurnId.make("turn-1"), + ...(sequence === null ? {} : { sequence }), + createdAt: "2026-04-01T11:00:00.000Z", + }); + // Snapshot loads deliver null-sequence rows first (DB order), which + // activityOrder sorts last; an in-order live append must not freeze + // that prefix. + const result = applyThreadDetailEvent( + { + ...baseThread, + activities: [makeActivity("activity-null", null), makeActivity("activity-a", 1)], + }, + { + ...baseEventFields, + sequence: 135, + occurredAt: "2026-04-01T11:01:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.activity-appended", + payload: { + threadId: ThreadId.make("thread-1"), + activity: makeActivity("activity-b", 2), + }, + }, + ); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.activities.map((activity) => activity.id)).toEqual([ + "activity-a", + "activity-b", + "activity-null", + ]); + } + }); + + it("dedupes a re-delivery arriving right after an in-order append", () => { + const makeActivity = (id: string, sequence: number, summary: string) => ({ + id: EventId.make(id), + tone: "tool" as const, + kind: "command", + summary, + payload: {}, + turnId: TurnId.make("turn-1"), + sequence, + createdAt: "2026-04-01T11:00:00.000Z", + }); + const makeEvent = (sequence: number, activity: ReturnType) => + ({ + ...baseEventFields, + sequence, + occurredAt: "2026-04-01T11:01:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.activity-appended", + payload: { threadId: ThreadId.make("thread-1"), activity }, + }) as const; + const first = applyThreadDetailEvent( + { ...baseThread, activities: [makeActivity("activity-a", 1, "first")] }, + makeEvent(133, makeActivity("activity-b", 2, "second")), + ); + expect(first.kind).toBe("updated"); + if (first.kind !== "updated") { + return; + } + const second = applyThreadDetailEvent( + first.thread, + makeEvent(134, makeActivity("activity-c", 3, "third")), + ); + expect(second.kind).toBe("updated"); + if (second.kind !== "updated") { + return; + } + const third = applyThreadDetailEvent( + second.thread, + makeEvent(135, makeActivity("activity-c", 4, "third (redelivered)")), + ); + expect(third.kind).toBe("updated"); + if (third.kind === "updated") { + expect(third.thread.activities.map((activity) => activity.id)).toEqual([ + "activity-a", + "activity-b", + "activity-c", + ]); + expect(third.thread.activities[2]?.summary).toBe("third (redelivered)"); + } + }); + + it("replaces a re-delivered activity instead of duplicating it", () => { + const makeActivity = (id: string, sequence: number, summary: string) => ({ + id: EventId.make(id), + tone: "tool" as const, + kind: "command", + summary, + payload: {}, + turnId: TurnId.make("turn-1"), + sequence, + createdAt: "2026-04-01T11:00:00.000Z", + }); + const result = applyThreadDetailEvent( + { + ...baseThread, + activities: [ + makeActivity("activity-a", 1, "first"), + makeActivity("activity-b", 2, "second"), + ], + }, + { + ...baseEventFields, + sequence: 132, + occurredAt: "2026-04-01T11:01:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.activity-appended", + payload: { + threadId: ThreadId.make("thread-1"), + activity: makeActivity("activity-b", 2, "second (redelivered)"), + }, + }, + ); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.activities.map((activity) => activity.id)).toEqual([ + "activity-a", + "activity-b", + ]); + expect(result.thread.activities[1]?.summary).toBe("second (redelivered)"); + } + }); + it("replaces earlier resolvable context-window updates for the same turn", () => { const contextWindowActivity = (id: string, sequence: number, usedTokens: unknown) => ({ id: EventId.make(id), diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 31f60bd2a..9b3c02592 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -35,6 +35,15 @@ const activityOrder = O.combineAll([ O.mapInput(O.String, (a) => a.id), ]); +// Per-array id index so the streaming append path can reject a re-delivered +// id without rescanning the history. Only arrays this reducer produced are +// indexed: presence also proves the array is activityOrder-sorted, which +// snapshot-loaded arrays (DB order, null sequences first) are not. +const activityIdIndex = new WeakMap< + ReadonlyArray, + Set +>(); + /** * Matches `deriveLatestContextWindowSnapshot`: clear activities are barriers, * while malformed updates are skipped during the consumer's backward walk and @@ -335,16 +344,18 @@ export function applyThreadDetailEvent( // assistant message only settles the turn once the session is no longer // running it — providers may emit several assistant messages per turn // (commentary between tool calls), and the turn must stay unsettled - // until the provider reports turn end. + // until the provider reports turn end. Streaming deltas recompute the + // same record, so the previous reference is kept when nothing changed. const turnStillRunning = event.payload.turnId !== null && thread.session?.status === "running" && thread.session.activeTurnId === event.payload.turnId; const settlesTurn = !event.payload.streaming && !turnStillRunning; - const latestTurn: OrchestrationThread["latestTurn"] = + const latestTurn = reuseLatestTurn( + thread.latestTurn, event.payload.role === "assistant" && - event.payload.turnId !== null && - (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) + event.payload.turnId !== null && + (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) ? { turnId: event.payload.turnId, state: settlesTurn @@ -369,9 +380,11 @@ export function applyThreadDetailEvent( : null, assistantMessageId: event.payload.messageId, } - : thread.latestTurn; + : thread.latestTurn, + ); - // Rebind checkpoint assistant message IDs for assistant messages. + // Rebind checkpoint assistant message IDs for assistant messages. The + // helper hands back the same array when the entry is already bound. const checkpoints = event.payload.role === "assistant" && event.payload.turnId !== null ? rebindCheckpointAssistantMessage( @@ -398,7 +411,8 @@ export function applyThreadDetailEvent( // Leaving the "running" session status is the turn-end signal: settle a // still-running latest turn so its duration reflects the whole turn. const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status); - const latestTurn: OrchestrationLatestTurn | null = + const latestTurn = reuseLatestTurn( + thread.latestTurn, event.payload.session.status === "running" && event.payload.session.activeTurnId !== null ? { turnId: event.payload.session.activeTurnId, @@ -428,7 +442,8 @@ export function applyThreadDetailEvent( // "running" is the authoritative turn end. completedAt: event.payload.session.updatedAt, } - : thread.latestTurn; + : thread.latestTurn, + ); return { kind: "updated", @@ -625,6 +640,31 @@ export function applyThreadDetailEvent( // thread.reverted that discards turns can still resolve a value from // the turns that survive. const supersedesContextWindow = isResolvableContextWindowActivity(activity); + // Live streams append in order: an unseen id sorting at/after the tail + // of a known-sorted array appends without re-filtering and re-sorting + // the whole history on every event. The id set moves forward to the new + // array; a superseded array falls back to the sorting path. + const ids = activityIdIndex.get(thread.activities); + const lastActivity = thread.activities.at(-1); + if ( + !supersedesContextWindow && + ids !== undefined && + (lastActivity === undefined || activityOrder(lastActivity, activity) <= 0) && + !ids.has(activity.id) + ) { + const activities = Arr.append(thread.activities, activity); + activityIdIndex.delete(thread.activities); + ids.add(activity.id); + activityIdIndex.set(activities, ids); + return { + kind: "updated", + thread: { + ...thread, + activities, + updatedAt: event.occurredAt, + }, + }; + } const activities = pipe( thread.activities, Arr.filter( @@ -639,6 +679,7 @@ export function applyThreadDetailEvent( Arr.append(activity), Arr.sort(activityOrder), ); + activityIdIndex.set(activities, new Set(activities.map((entry) => entry.id))); return { kind: "updated", @@ -695,11 +736,46 @@ function checkpointStatusToTurnState( } } +/** + * Returns `previous` when `next` matches it field for field, otherwise `next`. + * Streaming cases recompute the latest turn on every delta, and keeping the + * old reference lets selectors and memos keyed on `latestTurn` skip work. + */ +function reuseLatestTurn( + previous: OrchestrationLatestTurn | null, + next: OrchestrationLatestTurn | null, +): OrchestrationLatestTurn | null { + if (previous === null || next === null) { + return next; + } + return previous.turnId === next.turnId && + previous.state === next.state && + previous.requestedAt === next.requestedAt && + previous.startedAt === next.startedAt && + previous.completedAt === next.completedAt && + previous.assistantMessageId === next.assistantMessageId && + previous.sourceProposedPlan?.threadId === next.sourceProposedPlan?.threadId && + previous.sourceProposedPlan?.planId === next.sourceProposedPlan?.planId + ? previous + : next; +} + +/** + * Points the checkpoint for `turnId` at `messageId`. Returns the input array + * untouched when no checkpoint needs rebinding, so streaming deltas for an + * already-bound message do not allocate a new `checkpoints` reference. + */ function rebindCheckpointAssistantMessage( checkpoints: ReadonlyArray, turnId: TurnId, messageId: MessageId, -): OrchestrationCheckpointSummary[] { +): ReadonlyArray { + const needsRebind = checkpoints.some( + (entry) => entry.turnId === turnId && entry.assistantMessageId !== messageId, + ); + if (!needsRebind) { + return checkpoints; + } return Arr.map(checkpoints, (entry) => entry.turnId === turnId ? { ...entry, assistantMessageId: messageId } : entry, ); diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 042548336..a932e7398 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -31,6 +31,9 @@ import { const OFFLINE_BRANCH_LIST_LIMIT = 100; const VCS_REFS_IDLE_TTL_MS = 30_000; +// Rows keep the last status they rendered, so the live stream only needs a +// short grace period when virtualization or scrolling releases its consumer. +export const VCS_STATUS_IDLE_TTL_MS = 10_000; const VCS_REFS_RETRY_SCHEDULE = Schedule.exponential("1 second").pipe( Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.seconds(30))), @@ -275,6 +278,7 @@ export function createVcsEnvironmentAtoms( listRefs, status: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:vcs:status", + idleTtlMs: VCS_STATUS_IDLE_TTL_MS, subscribe: (input: EnvironmentRpcInput) => subscribe(WS_METHODS.subscribeVcsStatus, input).pipe( Stream.mapAccum( diff --git a/packages/contracts/src/desktopAppActivation.ts b/packages/contracts/src/desktopAppActivation.ts new file mode 100644 index 000000000..020188cf4 --- /dev/null +++ b/packages/contracts/src/desktopAppActivation.ts @@ -0,0 +1,53 @@ +import * as Schema from "effect/Schema"; + +import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION = 1 as const; + +export const DesktopAppActivationPlatform = Schema.Literals(["darwin", "linux", "win32"]); +export type DesktopAppActivationPlatform = typeof DesktopAppActivationPlatform.Type; + +export const DesktopAppActivationRequest = Schema.Struct({ + version: Schema.Literal(DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION), + requestId: TrimmedNonEmptyString, + type: Schema.Literal("open-workspace"), + workspaceRoot: TrimmedNonEmptyString, + platform: DesktopAppActivationPlatform, +}); +export type DesktopAppActivationRequest = typeof DesktopAppActivationRequest.Type; + +export const DesktopAppActivationErrorCode = Schema.Literals([ + "invalid-request", + "renderer-unavailable", + "environment-unavailable", + "platform-mismatch", + "project-create-failed", + "thread-open-failed", + "request-timeout", + "internal-error", +]); +export type DesktopAppActivationErrorCode = typeof DesktopAppActivationErrorCode.Type; + +export const DesktopAppActivationSuccess = Schema.Struct({ + version: Schema.Literal(DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION), + requestId: TrimmedNonEmptyString, + ok: Schema.Literal(true), + projectId: ProjectId, + threadId: ThreadId, +}); +export type DesktopAppActivationSuccess = typeof DesktopAppActivationSuccess.Type; + +export const DesktopAppActivationFailure = Schema.Struct({ + version: Schema.Literal(DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION), + requestId: TrimmedNonEmptyString, + ok: Schema.Literal(false), + code: DesktopAppActivationErrorCode, + message: TrimmedNonEmptyString, +}); +export type DesktopAppActivationFailure = typeof DesktopAppActivationFailure.Type; + +export const DesktopAppActivationResponse = Schema.Union([ + DesktopAppActivationSuccess, + DesktopAppActivationFailure, +]); +export type DesktopAppActivationResponse = typeof DesktopAppActivationResponse.Type; diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 4ea86670f..5dda491b0 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -4,6 +4,7 @@ import * as Schema from "effect/Schema"; import { VcsCreateWorktreeInput, GitPreparePullRequestThreadInput, + GitPreparePullRequestThreadResult, GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, @@ -13,6 +14,9 @@ const decodeCreateWorktreeInput = Schema.decodeUnknownSync(VcsCreateWorktreeInpu const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( GitPreparePullRequestThreadInput, ); +const decodePreparePullRequestThreadResult = Schema.decodeUnknownSync( + GitPreparePullRequestThreadResult, +); const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput); const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult); const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); @@ -55,6 +59,43 @@ describe("GitPreparePullRequestThreadInput", () => { }); }); +describe("GitPreparePullRequestThreadResult", () => { + it("defaults legacy responses to the pull request head", () => { + const parsed = decodePreparePullRequestThreadResult({ + pullRequest: { + number: 42, + title: "PR threads", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseBranch: "main", + headBranch: "feature/pr-threads", + state: "open", + }, + branch: "feature/pr-threads", + worktreePath: "/tmp/pr-threads", + }); + + expect(parsed.isOnPullRequestHead).toBe(true); + }); + + it("preserves an explicit stale pull request checkout result", () => { + const parsed = decodePreparePullRequestThreadResult({ + pullRequest: { + number: 42, + title: "PR threads", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseBranch: "main", + headBranch: "feature/pr-threads", + state: "open", + }, + branch: "feature/pr-threads", + worktreePath: "/tmp/pr-threads", + isOnPullRequestHead: false, + }); + + expect(parsed.isOnPullRequestHead).toBe(false); + }); +}); + describe("GitResolvePullRequestResult", () => { it("decodes resolved pull request metadata", () => { const parsed = decodeResolvePullRequestResult({ diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 915c3627c..d39be34bf 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,3 +1,4 @@ +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; @@ -289,7 +290,7 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({ * holding local commits or uncommitted changes keeps its own state, so the code being handed * over is older than the pull request. */ - isOnPullRequestHead: Schema.Boolean, + isOnPullRequestHead: Schema.Boolean.pipe(Schema.withDecodingDefaultKey(Effect.succeed(true))), }); export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 92e06bb5f..76b36aa78 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,6 +5,7 @@ export * from "./environment.ts"; export * from "./environmentHttp.ts"; export * from "./relayClient.ts"; export * from "./desktopBootstrap.ts"; +export * from "./desktopAppActivation.ts"; export * from "./remoteAccess.ts"; export * from "./ipc.ts"; export * from "./terminal.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index c023770df..eb4907f46 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -101,6 +101,10 @@ import type { SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; +import type { + DesktopAppActivationRequest, + DesktopAppActivationResponse, +} from "./desktopAppActivation.ts"; export interface ContextMenuItem { id: T; @@ -190,12 +194,6 @@ export interface DesktopRuntimeInfo { runningUnderArm64Translation: boolean; } -export const DesktopRuntimeInfoSchema = Schema.Struct({ - hostArch: DesktopRuntimeArchSchema, - appArch: DesktopRuntimeArchSchema, - runningUnderArm64Translation: Schema.Boolean, -}); - export interface DesktopUpdateState { enabled: boolean; status: DesktopUpdateStatus; @@ -207,6 +205,7 @@ export interface DesktopUpdateState { availableVersion: string | null; downloadedVersion: string | null; releaseNotes: ReadonlyArray; + omittedReleaseCount: number; downloadPercent: number | null; checkedAt: string | null; message: string | null; @@ -217,11 +216,13 @@ export interface DesktopUpdateState { export interface DesktopUpdateReleaseNote { version: string; items: ReadonlyArray; + totalItems: number; } export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ version: Schema.String, items: Schema.Array(Schema.String), + totalItems: Schema.Number, }); export const DesktopUpdateStateSchema = Schema.Struct({ @@ -235,6 +236,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({ availableVersion: Schema.NullOr(Schema.String), downloadedVersion: Schema.NullOr(Schema.String), releaseNotes: Schema.Array(DesktopUpdateReleaseNoteSchema), + omittedReleaseCount: Schema.Number, downloadPercent: Schema.NullOr(Schema.Number), checkedAt: Schema.NullOr(Schema.String), message: Schema.NullOr(Schema.String), @@ -343,14 +345,6 @@ export interface DesktopSshPasswordPromptRequest { expiresAt: string; } -export const DesktopSshPasswordPromptRequestSchema = Schema.Struct({ - requestId: Schema.String, - destination: Schema.String, - username: Schema.NullOr(Schema.String), - prompt: Schema.String, - expiresAt: Schema.String, -}); - export const DesktopSshPasswordPromptCancelledType = "ssh-password-prompt-cancelled" as const; export const DesktopSshPasswordPromptCancelledResultSchema = Schema.Struct({ @@ -611,22 +605,6 @@ export const DesktopPreviewNavStatusSchema = Schema.Union([ }), ]); -export const DesktopPreviewTabStateSchema: Schema.Codec = Schema.Struct({ - tabId: DesktopPreviewTabIdSchema, - webContentsId: Schema.NullOr(Schema.Int), - navStatus: DesktopPreviewNavStatusSchema, - canGoBack: Schema.Boolean, - canGoForward: Schema.Boolean, - zoomFactor: Schema.Number, - pictureInPicture: Schema.Boolean, - colorScheme: DesktopPreviewColorSchemeSchema, - audioMuted: Schema.Boolean, - audible: Schema.Boolean, - controller: Schema.Literals(["human", "agent", "none"]), - favicon: Schema.optionalKey(DesktopPreviewFaviconSchema), - updatedAt: Schema.String, -}); - export interface DesktopPreviewPointerEvent { tabId: string; phase: "move" | "click"; @@ -636,16 +614,6 @@ export interface DesktopPreviewPointerEvent { createdAt: string; } -export const DesktopPreviewPointerEventSchema: Schema.Codec = - Schema.Struct({ - tabId: DesktopPreviewTabIdSchema, - phase: Schema.Literals(["move", "click"]), - x: Schema.Number, - y: Schema.Number, - sequence: Schema.Int, - createdAt: Schema.String, - }); - /** * Static config a renderer needs to mount a preview ``. Returned * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer @@ -725,15 +693,6 @@ export interface DesktopPreviewRecordingFrame { receivedAt: string; } -export const DesktopPreviewRecordingFrameSchema: Schema.Codec = - Schema.Struct({ - tabId: DesktopPreviewTabIdSchema, - data: Schema.String, - width: Schema.Number, - height: Schema.Number, - receivedAt: Schema.String, - }); - export interface DesktopPreviewRecordingSource { sourceId: string; width: number; @@ -1100,6 +1059,8 @@ export interface DesktopBridge { setConnectionCatalog?: (catalog: string) => Promise; clearConnectionCatalog?: () => Promise; discoverSshHosts: () => Promise; + /** Resolves a suggested SSH alias before populating the connection form. */ + resolveSshHost: (alias: string) => Promise; ensureSshEnvironment: ( target: DesktopSshEnvironmentTarget, options?: { issuePairingToken?: boolean }, @@ -1164,6 +1125,12 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + /** Present when the desktop shell accepts `t3 app` activation requests. */ + appActivation?: { + setReady: (ready: boolean) => Promise; + complete: (response: DesktopAppActivationResponse) => Promise; + onRequest: (listener: (request: DesktopAppActivationRequest) => void) => () => void; + }; /** * Desktop-only preview surface. Present iff the renderer is hosted by the * Electron desktop build; web builds have `preview === undefined`. diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index c1444d905..cd14e58d0 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -1532,23 +1532,5 @@ export type ProviderRuntimeEventV2 = typeof ProviderRuntimeEventV2.Type; export const ProviderRuntimeEvent = ProviderRuntimeEventV2; export type ProviderRuntimeEvent = ProviderRuntimeEventV2; -// Compatibility aliases for call sites still importing legacy names. -const ProviderRuntimeMessageDeltaEvent = ProviderRuntimeContentDeltaEvent; -export type ProviderRuntimeMessageDeltaEvent = ProviderRuntimeContentDeltaEvent; -const ProviderRuntimeMessageCompletedEvent = ProviderRuntimeItemCompletedEvent; -export type ProviderRuntimeMessageCompletedEvent = ProviderRuntimeItemCompletedEvent; -const ProviderRuntimeToolStartedEvent = ProviderRuntimeItemStartedEvent; -export type ProviderRuntimeToolStartedEvent = ProviderRuntimeItemStartedEvent; -const ProviderRuntimeToolCompletedEvent = ProviderRuntimeItemCompletedEvent; -export type ProviderRuntimeToolCompletedEvent = ProviderRuntimeItemCompletedEvent; -const ProviderRuntimeApprovalRequestedEvent = ProviderRuntimeRequestOpenedEvent; -export type ProviderRuntimeApprovalRequestedEvent = ProviderRuntimeRequestOpenedEvent; -const ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent; -export type ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent; - -// Legacy helper aliases retained for adapters/tests. -const ProviderRuntimeToolKind = Schema.Literals(["command", "file-read", "file-change", "other"]); -export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type; - export const ProviderRuntimeTurnStatus = RuntimeTurnState; export type ProviderRuntimeTurnStatus = RuntimeTurnState; diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index a49868937..ce7fd0973 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -590,6 +590,24 @@ export const PullRequestRef = Schema.Struct({ }); export type PullRequestRef = typeof PullRequestRef.Type; +/** + * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar + * status check never loads permissions, repository settings, checks, or base comparison data. + */ +export const PullRequestSummary = Schema.Struct({ + provider: SourceControlProviderKind, + projectId: ProjectId, + repository: TrimmedNonEmptyString, + number: PositiveInt, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + state: PullRequestState, + headBranch: TrimmedNonEmptyString, + baseBranch: TrimmedNonEmptyString, + updatedAt: IsoDateTime, +}); +export type PullRequestSummary = typeof PullRequestSummary.Type; + /** * One row's line counts, read after the listing rather than inside it. On GitHub the pair is * 40-60% of the wall clock of the search that answers the whole page — measured over twelve @@ -660,6 +678,7 @@ export const PullRequestDetail = Schema.Struct({ deletions: NonNegativeInt, changedFiles: NonNegativeInt, headBranch: TrimmedNonEmptyString, + headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), baseBranch: TrimmedNonEmptyString, createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 8bf3aa266..774117c47 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -139,6 +139,7 @@ import { PullRequestOperationError, PullRequestReactionInput, PullRequestRef, + PullRequestSummary, PullRequestReviewerCandidateList, PullRequestReviewerRequestInput, PullRequestSubmitReviewInput, @@ -390,6 +391,7 @@ export const WS_METHODS = { // Pull request methods pullRequestsList: "pullRequests.list", pullRequestsListStats: "pullRequests.listStats", + pullRequestsSummary: "pullRequests.summary", pullRequestsDetail: "pullRequests.detail", pullRequestsActivity: "pullRequests.activity", pullRequestsThreadComments: "pullRequests.threadComments", @@ -811,6 +813,12 @@ export const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListSt error: PullRequestRpcError, }); +export const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, { + payload: PullRequestRef, + success: PullRequestSummary, + error: PullRequestRpcError, +}); + export const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { payload: PullRequestRef, success: PullRequestDetail, @@ -1377,6 +1385,7 @@ export const WsRpcGroup = RpcGroup.make( WsCloudInstallRelayClientRpc, WsPullRequestsListRpc, WsPullRequestsListStatsRpc, + WsPullRequestsSummaryRpc, WsPullRequestsDetailRpc, WsPullRequestsActivityRpc, WsPullRequestsThreadCommentsRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 15ebc9d11..6c244eee8 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -106,6 +106,18 @@ export const ServerProviderSkill = Schema.Struct({ enabled: Schema.Boolean, displayName: Schema.optional(TrimmedNonEmptyString), shortDescription: Schema.optional(TrimmedNonEmptyString), + /** + * The skill is hidden from the agent's own skill tool, so only the user can + * start it — Claude Code's `disable-model-invocation`. Composers must offer + * it as a slash command; naming it in prose does nothing. + */ + userInvocationOnly: Schema.optional(Schema.Boolean), + /** + * The mirror of {@link ServerProviderSkill.userInvocationOnly}: Claude Code's + * `user-invocable: false` keeps the skill out of its own slash commands, so + * only the agent can start it. Composers must not offer it under `/`. + */ + userInvocable: Schema.optional(Schema.Boolean), }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7b2a4ed35..903dc25d1 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -287,10 +287,6 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS // ── Server Settings (server-authoritative) ──────────────────── -// Moved to environment.ts so orchestration contracts can use it without an -// import cycle; re-exported here for compatibility with deep imports. -export { ThreadEnvMode } from "./environment.ts"; - const makeBinaryPathSetting = (fallback: string) => TrimmedString.pipe( Schema.decodeTo( diff --git a/packages/effect-acp/package.json b/packages/effect-acp/package.json index 4455dd460..7f1aa3b10 100644 --- a/packages/effect-acp/package.json +++ b/packages/effect-acp/package.json @@ -15,18 +15,10 @@ "types": "./src/schema.ts", "import": "./src/schema.ts" }, - "./rpc": { - "types": "./src/rpc.ts", - "import": "./src/rpc.ts" - }, "./protocol": { "types": "./src/protocol.ts", "import": "./src/protocol.ts" }, - "./terminal": { - "types": "./src/terminal.ts", - "import": "./src/terminal.ts" - }, "./errors": { "types": "./src/errors.ts", "import": "./src/errors.ts" diff --git a/packages/effect-acp/test/examples/cursor-acp-client.example.ts b/packages/effect-acp/test/examples/cursor-acp-client.example.ts deleted file mode 100644 index b7a146cf5..000000000 --- a/packages/effect-acp/test/examples/cursor-acp-client.example.ts +++ /dev/null @@ -1,81 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Console from "effect/Console"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; - -import * as AcpClient from "../../src/client.ts"; - -const program = Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("cursor-agent", ["acp"], { - cwd: process.cwd(), - shell: false, - }); - const handle = yield* spawner.spawn(command); - const acpLayer = AcpClient.layerChildProcess(handle, { - logIncoming: true, - logOutgoing: true, - }); - - yield* Effect.gen(function* () { - const acp = yield* AcpClient.AcpClient; - - yield* acp.handleRequestPermission(() => - Effect.succeed({ - outcome: { - outcome: "selected", - optionId: "allow", - }, - }), - ); - // yield* acp.handleSessionUpdate((notification) => - // Console.log("session/update", JSON.stringify(notification)), - // ); - - const initialized = yield* acp.agent.initialize({ - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - _meta: { - parameterizedModelPicker: true, - }, - }, - clientInfo: { - name: "effect-acp-example", - version: "0.0.0", - }, - }); - yield* Console.log("initialized", initialized); - - const session = yield* acp.agent.createSession({ - cwd: process.cwd(), - mcpServers: [], - }); - - const config = yield* acp.agent.setSessionConfigOption({ - sessionId: session.sessionId, - configId: "model", - value: "claude-opus-4-6", - }); - - yield* Console.log("config", config); - - const result = yield* acp.agent.prompt({ - sessionId: session.sessionId, - prompt: [ - { - type: "text", - text: "Illustrate your ability to create todo lists and then execute all of them. Do not write the list to disk, illustrate your built in ability!", - }, - ], - }); - - yield* Console.log("prompt result", result); - yield* acp.agent.cancel({ sessionId: session.sessionId }); - }).pipe(Effect.provide(acpLayer)); -}); - -program.pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain); diff --git a/packages/effect-codex-app-server/package.json b/packages/effect-codex-app-server/package.json index a067976c6..d8894a998 100644 --- a/packages/effect-codex-app-server/package.json +++ b/packages/effect-codex-app-server/package.json @@ -15,10 +15,6 @@ "types": "./src/rpc.ts", "import": "./src/rpc.ts" }, - "./protocol": { - "types": "./src/protocol.ts", - "import": "./src/protocol.ts" - }, "./errors": { "types": "./src/errors.ts", "import": "./src/errors.ts" diff --git a/packages/shared/package.json b/packages/shared/package.json index 7e2d7b20a..1db698b0a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -235,9 +235,17 @@ "types": "./src/usageFormat.ts", "import": "./src/usageFormat.ts" }, + "./desktopAppControl": { + "types": "./src/desktopAppControl.ts", + "import": "./src/desktopAppControl.ts" + }, "./claudeCompaction": { "types": "./src/claudeCompaction.ts", "import": "./src/claudeCompaction.ts" + }, + "./nodeSqliteClient": { + "types": "./src/nodeSqliteClient.ts", + "import": "./src/nodeSqliteClient.ts" } }, "scripts": { diff --git a/packages/shared/src/desktopAppControl.test.ts b/packages/shared/src/desktopAppControl.test.ts new file mode 100644 index 000000000..cd50b7ae0 --- /dev/null +++ b/packages/shared/src/desktopAppControl.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveDesktopAppControlAddress } from "./desktopAppControl.ts"; + +describe("resolveDesktopAppControlAddress", () => { + it("keeps Unix socket paths short and separates desktop state directories", () => { + const first = resolveDesktopAppControlAddress({ + stateDir: `/home/user/${"long/".repeat(40)}userdata`, + platform: "linux", + tempDir: "/tmp", + userId: 1000, + joinPath: (...segments) => segments.join("/"), + }); + const second = resolveDesktopAppControlAddress({ + stateDir: "/home/user/.t3/other/userdata", + platform: "linux", + tempDir: "/tmp", + userId: 1000, + joinPath: (...segments) => segments.join("/"), + }); + + expect(first.directory).toBe("/tmp/t3code-1000"); + expect(first.address.length).toBeLessThan(108); + expect(first.address).not.toBe(second.address); + }); + + it("uses a Windows named pipe", () => { + const result = resolveDesktopAppControlAddress({ + stateDir: "C:\\Users\\user\\.t3\\userdata", + platform: "win32", + tempDir: "C:\\Temp", + userId: undefined, + joinPath: (...segments) => segments.join("\\"), + }); + + expect(result.directory).toBeNull(); + expect(result.address).toMatch(/^\\\\\.\\pipe\\t3code-app-[a-f0-9]{24}$/); + }); +}); diff --git a/packages/shared/src/desktopAppControl.ts b/packages/shared/src/desktopAppControl.ts new file mode 100644 index 000000000..42fc391d8 --- /dev/null +++ b/packages/shared/src/desktopAppControl.ts @@ -0,0 +1,41 @@ +import { sha256 } from "@noble/hashes/sha2"; + +export interface DesktopAppControlAddress { + readonly address: string; + readonly directory: string | null; +} + +function shortHash(value: string): string { + return Array.from(sha256(new TextEncoder().encode(value)).slice(0, 12), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +/** + * Returns the local-only socket address shared by the desktop shell and CLI. + * The state directory is hashed so custom T3 homes cannot exceed Unix socket + * path limits. + */ +export function resolveDesktopAppControlAddress(input: { + readonly stateDir: string; + readonly platform: NodeJS.Platform; + readonly tempDir: string; + readonly userId: number | undefined; + readonly joinPath: (...segments: readonly string[]) => string; +}): DesktopAppControlAddress { + const stateHash = shortHash(input.stateDir); + if (input.platform === "win32") { + return { + address: `\\\\.\\pipe\\t3code-app-${stateHash}`, + directory: null, + }; + } + + const userKey = + input.userId === undefined ? shortHash(input.stateDir).slice(0, 12) : input.userId; + const directory = input.joinPath(input.tempDir, `t3code-${userKey}`); + return { + address: input.joinPath(directory, `${stateHash}.sock`), + directory, + }; +} diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index ed9178590..3d2945941 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,6 +1,4 @@ import { - DEFAULT_MODEL, - DEFAULT_MODEL_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, type ModelCapabilities, type ModelSelection, @@ -41,6 +39,17 @@ export function getProviderOptionSelectionValue( return getRawSelectionValueById(selections, id); } +/** + * Read one provider option off a model selection. Kept in Pylon because the + * Prime Agent backend resolves thinking level and service tier this way. + */ +export function getModelSelectionOptionValue( + modelSelection: ModelSelection | null | undefined, + id: string, +): string | boolean | undefined { + return getProviderOptionSelectionValue(modelSelection?.options, id); +} + export function getProviderOptionStringSelectionValue( selections: ReadonlyArray | null | undefined, id: string, @@ -57,13 +66,6 @@ export function getProviderOptionBooleanSelectionValue( return typeof value === "boolean" ? value : undefined; } -export function getModelSelectionOptionValue( - modelSelection: ModelSelection | null | undefined, - id: string, -): string | boolean | undefined { - return getProviderOptionSelectionValue(modelSelection?.options, id); -} - export function getModelSelectionStringOptionValue( modelSelection: ModelSelection | null | undefined, id: string, @@ -213,22 +215,6 @@ export function buildProviderOptionSelectionsFromDescriptors( return nextSelections.length > 0 ? nextSelections : undefined; } -export function getModelSelectionOptionDescriptors( - modelSelection: ModelSelection | null | undefined, - caps?: ModelCapabilities | null | undefined, -): ReadonlyArray { - if (!modelSelection) { - return []; - } - if (!caps) { - return []; - } - return getProviderOptionDescriptors({ - caps, - selections: modelSelection.options, - }); -} - export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { return typeof text === "string" && /\bultrathink\b/i.test(text); } @@ -298,21 +284,6 @@ export function resolveSelectableModel( return resolved ? resolved.slug : null; } -function resolveModelSlug(model: string | null | undefined, provider: ProviderDriverKind): string { - const normalized = normalizeModelSlug(model, provider); - if (!normalized) { - return DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; - } - return normalized; -} - -export function resolveModelSlugForProvider( - provider: ProviderDriverKind, - model: string | null | undefined, -): string { - return resolveModelSlug(model, provider); -} - /** Trim a string, returning null for empty/missing values. */ export function trimOrNull(value: T | null | undefined): T | null { if (typeof value !== "string") return null; diff --git a/apps/server/src/persistence/NodeSqliteClient.test.ts b/packages/shared/src/nodeSqliteClient.test.ts similarity index 97% rename from apps/server/src/persistence/NodeSqliteClient.test.ts rename to packages/shared/src/nodeSqliteClient.test.ts index b17d3e0eb..6738892a9 100644 --- a/apps/server/src/persistence/NodeSqliteClient.test.ts +++ b/packages/shared/src/nodeSqliteClient.test.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqliteClient from "./NodeSqliteClient.ts"; +import * as SqliteClient from "./nodeSqliteClient.ts"; const layer = it.layer(SqliteClient.layerMemory()); diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts similarity index 100% rename from apps/server/src/persistence/NodeSqliteClient.ts rename to packages/shared/src/nodeSqliteClient.ts diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 3307eb460..e132b7084 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -119,19 +119,6 @@ export const decodeJsonResult = >( - schema: S, -) => { - const decode = Schema.decodeUnknownExit(Schema.fromJsonString(schema)); - return (input: unknown) => { - const result = decode(input); - if (Exit.isFailure(result)) { - return Result.fail(result.cause); - } - return Result.succeed(result.value); - }; -}; - export const formatSchemaError = (cause: Cause.Cause) => { const issues: Array = []; let issueCount = 0; diff --git a/packages/shared/src/themePalettes.ts b/packages/shared/src/themePalettes.ts index 708b89a9d..73b73a4de 100644 --- a/packages/shared/src/themePalettes.ts +++ b/packages/shared/src/themePalettes.ts @@ -764,10 +764,6 @@ export const BUILT_IN_THEMES: ReadonlyArray = [ IRIS_THEME, ]; -export function getBuiltInTheme(id: string): ThemeDefinition | null { - return BUILT_IN_THEMES.find((theme) => theme.id === id) ?? null; -} - export function getThemeColorsForAppearance( theme: ThemeDefinition, appearance: ThemeAppearance, diff --git a/packages/tailscale/package.json b/packages/tailscale/package.json index ce020dc8e..306ec104a 100644 --- a/packages/tailscale/package.json +++ b/packages/tailscale/package.json @@ -13,7 +13,6 @@ "test": "vp test run" }, "dependencies": { - "@effect/platform-node": "catalog:", "@t3tools/shared": "workspace:*", "effect": "catalog:" }, diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index fedde02ee..d6db5e8bc 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -382,23 +382,3 @@ export const probeTailscaleHttpsEndpoint = (input: { onSome: (httpResponse) => httpResponse.status >= 200 && httpResponse.status < 300, }); }).pipe(Effect.orElseSucceed(() => false)); - -export const resolveTailscaleHttpsBaseUrl = ( - input: { - readonly servePort?: number; - } = {}, -): Effect.Effect< - string | null, - TailscaleCommandError | TailscaleStatusParseError, - ChildProcessSpawner.ChildProcessSpawner -> => - readTailscaleStatus.pipe( - Effect.map((status) => - status.magicDnsName - ? buildTailscaleHttpsBaseUrl({ - magicDnsName: status.magicDnsName, - ...(input.servePort === undefined ? {} : { servePort: input.servePort }), - }) - : null, - ), - ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0770b4e95..91d6fc817 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -395,9 +395,6 @@ importers: expo-widgets: specifier: ~57.0.15 version: 57.0.15(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - punycode: - specifier: ^2.3.1 - version: 2.3.1 react: specifier: 19.2.3 version: 19.2.3 @@ -716,9 +713,6 @@ importers: compression: specifier: ^1.8.1 version: 1.8.1 - msw: - specifier: 2.12.11 - version: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) tailwindcss: specifier: ^4.0.0 version: 4.3.0 @@ -961,9 +955,6 @@ importers: packages/tailscale: dependencies: - '@effect/platform-node': - specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/shared': specifier: workspace:* version: link:../shared @@ -992,9 +983,6 @@ importers: '@electron/osx-sign': specifier: 2.7.0 version: 2.7.0 - '@t3tools/contracts': - specifier: workspace:* - version: link:../packages/contracts '@t3tools/shared': specifier: workspace:* version: link:../packages/shared @@ -13329,7 +13317,8 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inquirer/ansi@1.0.2': {} + '@inquirer/ansi@1.0.2': + optional: true '@inquirer/confirm@5.1.21(@types/node@24.12.4)': dependencies: @@ -13337,6 +13326,7 @@ snapshots: '@inquirer/type': 3.0.10(@types/node@24.12.4) optionalDependencies: '@types/node': 24.12.4 + optional: true '@inquirer/core@10.3.2(@types/node@24.12.4)': dependencies: @@ -13350,12 +13340,15 @@ snapshots: yoctocolors-cjs: 2.1.3 optionalDependencies: '@types/node': 24.12.4 + optional: true - '@inquirer/figures@1.0.15': {} + '@inquirer/figures@1.0.15': + optional: true '@inquirer/type@3.0.10(@types/node@24.12.4)': optionalDependencies: '@types/node': 24.12.4 + optional: true '@ioredis/commands@1.10.0': {} @@ -13709,6 +13702,7 @@ snapshots: is-node-process: 1.2.0 outvariant: 1.4.3 strict-event-emitter: 0.5.1 + optional: true '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: @@ -13839,14 +13833,17 @@ snapshots: '@octokit/request-error': 7.1.0 '@octokit/webhooks-methods': 6.0.0 - '@open-draft/deferred-promise@2.2.0': {} + '@open-draft/deferred-promise@2.2.0': + optional: true '@open-draft/logger@0.3.0': dependencies: is-node-process: 1.2.0 outvariant: 1.4.3 + optional: true - '@open-draft/until@2.1.0': {} + '@open-draft/until@2.1.0': + optional: true '@opencode-ai/sdk@1.15.13': dependencies: @@ -15300,7 +15297,8 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 24.12.4 - '@types/statuses@2.0.6': {} + '@types/statuses@2.0.6': + optional: true '@types/unist@2.0.11': {} @@ -16389,7 +16387,8 @@ snapshots: slice-ansi: 8.0.0 string-width: 8.2.1 - cli-width@4.1.0: {} + cli-width@4.1.0: + optional: true cliui@8.0.1: dependencies: @@ -17891,7 +17890,8 @@ snapshots: graphmatch@1.1.1: {} - graphql@16.14.1: {} + graphql@16.14.1: + optional: true h3@1.15.11: dependencies: @@ -18039,7 +18039,8 @@ snapshots: property-information: 7.2.0 space-separated-tokens: 2.0.2 - headers-polyfill@4.0.3: {} + headers-polyfill@4.0.3: + optional: true heic-to@1.5.2: {} @@ -18248,7 +18249,8 @@ snapshots: is-interactive@2.0.0: {} - is-node-process@1.2.0: {} + is-node-process@1.2.0: + optional: true is-number@7.0.0: {} @@ -19590,6 +19592,7 @@ snapshots: typescript: 6.0.3 transitivePeerDependencies: - '@types/node' + optional: true muggle-string@0.4.1: {} @@ -19597,7 +19600,8 @@ snapshots: multitars@1.0.2: {} - mute-stream@2.0.0: {} + mute-stream@2.0.0: + optional: true mysql2@3.23.2(@types/node@24.12.4): dependencies: @@ -19825,7 +19829,8 @@ snapshots: stdin-discarder: 0.3.2 string-width: 8.2.1 - outvariant@1.4.3: {} + outvariant@1.4.3: + optional: true oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: @@ -20207,7 +20212,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 - punycode@2.3.1: {} + punycode@2.3.1: + optional: true pure-rand@8.4.0: {} @@ -20793,7 +20799,8 @@ snapshots: retry@0.12.0: {} - rettime@0.10.1: {} + rettime@0.10.1: + optional: true reusify@1.1.0: {} @@ -21253,7 +21260,8 @@ snapshots: - bare-abort-controller - react-native-b4a - strict-event-emitter@0.5.1: {} + strict-event-emitter@0.5.1: + optional: true strict-uri-encode@2.0.0: {} @@ -21465,11 +21473,13 @@ snapshots: tinyrainbow@3.1.0: {} - tldts-core@7.4.2: {} + tldts-core@7.4.2: + optional: true tldts@7.4.2: dependencies: tldts-core: 7.4.2 + optional: true tmp-promise@3.0.3: dependencies: @@ -21494,6 +21504,7 @@ snapshots: tough-cookie@6.0.1: dependencies: tldts: 7.4.2 + optional: true tr46@0.0.3: {} @@ -21686,7 +21697,8 @@ snapshots: idb-keyval: 6.2.1 ioredis: 5.11.0 - until-async@3.0.2: {} + until-async@3.0.2: + optional: true unzipper@0.12.5: dependencies: @@ -22048,6 +22060,7 @@ snapshots: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + optional: true wrap-ansi@7.0.0: dependencies: @@ -22144,7 +22157,8 @@ snapshots: yocto-queue@1.2.2: {} - yoctocolors-cjs@2.1.3: {} + yoctocolors-cjs@2.1.3: + optional: true yoctocolors@2.1.2: {} diff --git a/scripts/announce-connect-ga.ts b/scripts/announce-connect-ga.ts deleted file mode 100644 index 88a4db56c..000000000 --- a/scripts/announce-connect-ga.ts +++ /dev/null @@ -1,226 +0,0 @@ -#!/usr/bin/env node - -import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Config from "effect/Config"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { Command, Flag } from "effect/unstable/cli"; -import { - FetchHttpClient, - HttpClient, - HttpClientRequest, - HttpClientResponse, -} from "effect/unstable/http"; - -const CLERK_API_URL = "https://api.clerk.com/v1"; -const PAGE_SIZE = 500; - -export class WaitlistEntry extends Schema.Class("WaitlistEntry")({ - id: Schema.String, - email_address: Schema.String, - status: Schema.Literals(["pending", "invited", "completed", "rejected"]), -}) {} - -const ClerkWaitlistResponse = Schema.Struct({ - data: Schema.Array(WaitlistEntry), - total_count: Schema.Int, -}); -const PositiveInteger = Schema.Int.check(Schema.isGreaterThan(0)); -const ClerkSecretKey = Config.string("CLERK_SECRET_KEY"); - -export interface ConnectGaOptions { - readonly invite: boolean; - readonly limit: number | undefined; -} - -export class ConnectGaRequestError extends Schema.TaggedErrorClass()( - "ConnectGaRequestError", - { - operation: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Clerk ${this.operation} request failed.`; - } -} - -export class ConnectGaResponseError extends Schema.TaggedErrorClass()( - "ConnectGaResponseError", - { - operation: Schema.String, - status: Schema.Int, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Clerk ${this.operation} returned status ${this.status}.`; - } -} - -const executeClerkJsonRequest = Effect.fn("executeClerkJsonRequest")(function* < - S extends Schema.Top, ->(request: HttpClientRequest.HttpClientRequest, schema: S, operation: string) { - const client = (yield* HttpClient.HttpClient).pipe( - HttpClient.retryTransient({ - retryOn: "errors-and-responses", - times: 3, - }), - ); - const response = yield* client - .execute(request) - .pipe(Effect.mapError((cause) => new ConnectGaRequestError({ operation, cause }))); - const success = yield* HttpClientResponse.filterStatusOk(response).pipe( - Effect.mapError( - (cause) => - new ConnectGaResponseError({ - operation, - status: response.status, - cause, - }), - ), - ); - return yield* HttpClientResponse.schemaBodyJson(schema)(success).pipe( - Effect.mapError( - (cause) => - new ConnectGaResponseError({ - operation, - status: response.status, - cause, - }), - ), - ); -}); - -const fetchWaitlistPage = Effect.fn("fetchWaitlistPage")(function* ( - secretKey: string, - offset: number, -) { - const url = new URL(`${CLERK_API_URL}/waitlist_entries`); - url.searchParams.set("status", "pending"); - url.searchParams.set("limit", String(PAGE_SIZE)); - url.searchParams.set("offset", String(offset)); - url.searchParams.set("order_by", "+created_at"); - const request = HttpClientRequest.get(url.href).pipe( - HttpClientRequest.bearerToken(secretKey), - HttpClientRequest.setHeader("Clerk-API-Version", "2026-05-12"), - ); - return yield* executeClerkJsonRequest( - request, - ClerkWaitlistResponse, - "list pending waitlist entries", - ); -}); - -export const fetchPendingWaitlistEntries = Effect.fn("fetchPendingWaitlistEntries")(function* ( - secretKey: string, - limit?: number, -) { - const entries: Array = []; - while (true) { - if (limit !== undefined && entries.length >= limit) break; - const page = yield* fetchWaitlistPage(secretKey, entries.length); - entries.push(...page.data); - if (entries.length >= page.total_count || page.data.length === 0) break; - } - return limit === undefined ? entries : entries.slice(0, limit); -}); - -export const inviteWaitlistEntry = Effect.fn("inviteWaitlistEntry")(function* ( - secretKey: string, - entry: WaitlistEntry, -) { - const request = HttpClientRequest.post( - `${CLERK_API_URL}/waitlist_entries/${encodeURIComponent(entry.id)}/invite`, - ).pipe( - HttpClientRequest.bearerToken(secretKey), - HttpClientRequest.setHeader("Clerk-API-Version", "2026-05-12"), - ); - return yield* executeClerkJsonRequest( - request, - WaitlistEntry, - `invite waitlist entry ${entry.id}`, - ); -}); - -export const announceConnectGa = Effect.fn("announceConnectGa")(function* ( - options: ConnectGaOptions, -) { - const clerkSecretKey = yield* ClerkSecretKey; - const entries = yield* fetchPendingWaitlistEntries(clerkSecretKey, options.limit); - - yield* Effect.logInfo( - options.invite ? "Connect GA waitlist invitations starting" : "Connect GA dry run", - ).pipe( - Effect.annotateLogs({ - pendingEntries: entries.length, - }), - ); - - if (!options.invite) { - for (const entry of entries) { - yield* Effect.logInfo("pending waitlist entry").pipe( - Effect.annotateLogs({ - waitlistEntryId: entry.id, - emailAddress: entry.email_address, - }), - ); - } - yield* Effect.logInfo("No invitation was sent. Re-run with --invite after reviewing the list."); - return; - } - - for (const [index, entry] of entries.entries()) { - const invited = yield* inviteWaitlistEntry(clerkSecretKey, entry); - yield* Effect.logInfo("Clerk waitlist invitation sent").pipe( - Effect.annotateLogs({ - waitlistEntryId: invited.id, - completed: index + 1, - total: entries.length, - }), - ); - } -}); - -export const announceConnectGaCommand = Command.make( - "announce-connect-ga", - { - invite: Flag.boolean("invite").pipe( - Flag.withDefault(false), - Flag.withDescription( - "Invite pending entries through Clerk. Without this flag, only print a dry-run list.", - ), - ), - limit: Flag.integer("limit").pipe( - Flag.withSchema(PositiveInteger), - Flag.optional, - Flag.withDescription("Process at most this many pending waitlist entries."), - ), - }, - ({ invite, limit }) => - announceConnectGa({ - invite, - limit: Option.getOrUndefined(limit), - }), -).pipe( - Command.withDescription( - "Invite pending Clerk waitlist members now that T3 Connect is generally available.", - ), -); - -if (import.meta.main) { - Command.run(announceConnectGaCommand, { version: "0.0.0" }).pipe( - Effect.provide( - Layer.mergeAll( - Logger.layer([Logger.consolePretty()]), - NodeServices.layer, - FetchHttpClient.layer, - ), - ), - NodeRuntime.runMain, - ); -} diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 880dba20e..7a45f520c 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -991,6 +991,19 @@ export const MAC_FILE_EXCLUSIONS = [ "!**/node_modules/node-pty/prebuilds/win32-*/**/*", "!**/node_modules/node-pty/third_party/conpty/**/*", ] as const; + +// node-pty publishes both Darwin prebuilds in one package. Single-architecture +// apps only need the native target; universal apps need both. An omitted arch +// preserves the existing common exclusions for callers that only inspect the +// generic platform config. +export function resolveMacFileExclusions(arch?: typeof BuildArch.Type) { + if (arch === undefined || arch === "universal") { + return [...MAC_FILE_EXCLUSIONS]; + } + + const unusedArch = arch === "arm64" ? "x64" : "arm64"; + return [...MAC_FILE_EXCLUSIONS, `!**/node_modules/node-pty/prebuilds/darwin-${unusedArch}/**/*`]; +} // Windows ships the server tree (bundle + node_modules) as a separate // resources/server.asar sidecar instead of loose files: the NSIS installer // then extracts a handful of large archives instead of thousands of small @@ -2597,13 +2610,17 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( // sidecar staging skips the archive in that case, and listing a resource // whose source file was never written fails the electron-builder step. wslRuntimeBundled = false, + arch?: typeof BuildArch.Type, ) { const buildConfig: Record = { appId: resolveDesktopAppId(version), productName: resolveDesktopProductName(version), artifactName: "Pylon-${version}-${arch}.${ext}", electronLanguages: [...DESKTOP_ELECTRON_LANGUAGES], - files: [...DESKTOP_FILE_EXCLUSIONS, ...(platform === "mac" ? MAC_FILE_EXCLUSIONS : [])], + files: [ + ...DESKTOP_FILE_EXCLUSIONS, + ...(platform === "mac" ? resolveMacFileExclusions(arch) : []), + ], directories: { buildResources: "apps/desktop/resources", }, @@ -3679,6 +3696,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( : undefined, options.localMacSigningIdentity, bundlesWslRuntime({ arch: options.arch, prebuildPath: options.wslPrebuild }), + options.arch, ), dependencies: stageDependencies, devDependencies: { diff --git a/scripts/lib/dev-share.test.ts b/scripts/lib/dev-share.test.ts index b2dfba358..2b133e456 100644 --- a/scripts/lib/dev-share.test.ts +++ b/scripts/lib/dev-share.test.ts @@ -27,11 +27,16 @@ const encode = (value: string) => Stream.make(new TextEncoder().encode(value)); * test set the outcome of the `off` (pre-clear) and `serve` calls separately — * they are the same subcommand and are told apart by the trailing `off`. */ -const spawnerLayer = (input: { readonly off?: CallResult; readonly serve?: CallResult }) => +const spawnerLayer = (input: { + readonly off?: CallResult; + readonly serve?: CallResult; + readonly calls?: Array>; +}) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { const args = "args" in command ? (command.args as ReadonlyArray) : []; + input.calls?.push(args); const result: CallResult = args.includes("status") ? { exitCode: 0 } : args.includes("off") @@ -102,6 +107,20 @@ describe("shareDevServer", () => { }), ); + // Vite binds `localhost`, which modern Node resolves to `::1` first, so a + // 127.0.0.1 target would proxy to a loopback nothing listens on. + it.effect("proxies to the localhost name Vite binds, not 127.0.0.1", () => + Effect.gen(function* () { + const calls: Array> = []; + yield* shareDevServer({ webPort: 5788 }).pipe( + Effect.provide(spawnerLayer({ off: { exitCode: 0 }, calls })), + ); + + const serveCall = calls.find((args) => args.includes("--bg")); + assert.deepEqual(serveCall, ["serve", "--bg", "--https=5788", "http://localhost:5788"]); + }), + ); + // The stale-mapping clear runs before serve, so a failure here leaves the // port serving nothing. Saying only "serve failed" would let an operator // assume their previous mapping survived. diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts index 0f843b3ba..e3fb884eb 100644 --- a/scripts/lib/dev-share.ts +++ b/scripts/lib/dev-share.ts @@ -195,7 +195,17 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in }); } - yield* ensureTailscaleServe({ localPort: input.webPort, servePort: input.webPort }).pipe( + // Proxy to the hostname Vite binds rather than the package default of + // 127.0.0.1. Vite listens on `localhost`, which Node 17+ resolves to `::1` + // first, so it only binds the IPv6 loopback and a 127.0.0.1 target has + // nothing behind it (tailscale answers 502). Passing `localhost` lets the + // tailscale proxy resolve it the same way Node did. Not a literal `[::1]`: + // tailscale rejects that form. + yield* ensureTailscaleServe({ + localPort: input.webPort, + servePort: input.webPort, + localHost: "localhost", + }).pipe( Effect.mapError((error) => { const explanation = explainCommandFailure(error); return new DevServeFailedError({ diff --git a/scripts/package.json b/scripts/package.json index 6e61fb308..01fca4b87 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -10,7 +10,6 @@ "@effect/platform-node": "catalog:", "@electron/asar": "^3.4.1", "@electron/osx-sign": "2.7.0", - "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", "effect": "catalog:",