From d2d5bba7038678eaa5fc6a885fa5ab05d0854725 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 16:50:58 -0400 Subject: [PATCH 01/78] feat(web): pull request files can be marked as viewed A review spread over an afternoon, or picked up on a second machine, started again from the top every time, so large changes were read in the browser and only small ones stayed here. The marks are the host's rather than ours because a checkbox only this app remembers is worse than none: it looks like the one GitHub shows, disagrees with it, and leaves a reviewer unsure which of the two knows what they have actually read. Signed-off-by: Yordis Prieto --- apps/server/src/auth/RpcAuthorization.ts | 2 + .../pullRequest/GitHubPullRequestCli.test.ts | 144 +++++++++++++++++ .../src/pullRequest/GitHubPullRequestCli.ts | 118 +++++++++++++- .../pullRequest/GitHubPullRequestProvider.ts | 7 + .../src/pullRequest/PullRequestProvider.ts | 30 ++++ .../pullRequest/PullRequestService.test.ts | 81 ++++++++++ .../src/pullRequest/PullRequestService.ts | 112 ++++++++++++- .../pullRequest/gitHubPullRequestJson.test.ts | 96 ++++++++++++ .../src/pullRequest/gitHubPullRequestJson.ts | 120 ++++++++++++++ .../sourceControl/githubGraphQlBudget.test.ts | 27 ++++ .../src/sourceControl/githubGraphQlBudget.ts | 30 +++- apps/server/src/ws.ts | 10 ++ .../pullRequest/PullRequestCodeTab.tsx | 70 ++++++++- .../pullRequest/pullRequestDiff.logic.test.ts | 34 +++- .../pullRequest/pullRequestDiff.logic.ts | 21 +++ .../pullRequestFilesViewed.logic.test.ts | 100 ++++++++++++ .../pullRequestFilesViewed.logic.ts | 82 ++++++++++ .../pullRequest/usePullRequestFilesViewed.ts | 147 ++++++++++++++++++ docs/user/source-control.md | 15 ++ .../client-runtime/src/state/pullRequests.ts | 25 +++ packages/contracts/src/pullRequest.ts | 63 ++++++++ packages/contracts/src/rpc.ts | 23 +++ 22 files changed, 1339 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts create mode 100644 apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..57b18b11f596 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -58,6 +58,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsFilesViewed]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsUpdate]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope, @@ -66,6 +67,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsSetReaction]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsSetFilesViewed]: AuthOrchestrationOperateScope, // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only // client pressing refresh must not be told it may not look again. [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 33d0d120ccce..e114abc180cb 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -2603,4 +2603,148 @@ layer("GitHubPullRequestCli.layer", (it) => { ]); }), ); + + it.effect("reads every page of viewed files, and says so when there are too many", () => + Effect.gen(function* () { + const page = (index: number, hasNextPage: boolean) => + Effect.succeed( + output( + JSON.stringify({ + data: { + repository: { + pullRequest: { + files: { + pageInfo: { hasNextPage, endCursor: `cursor-${index}` }, + nodes: [ + { path: `src/file${index}.ts`, viewerViewedState: "VIEWED" }, + { path: `src/other${index}.ts`, viewerViewedState: "UNVIEWED" }, + ], + }, + }, + }, + }, + }), + ), + ); + mockedExecute + .mockReturnValueOnce(page(0, true)) + .mockReturnValueOnce(page(1, true)) + .mockReturnValueOnce(page(2, false)); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const viewed = yield* cli.getPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 3); + // The first page asks from the start; each one after it carries the cursor before it. + assert.isFalse(callAt(0).args.some((arg) => arg.startsWith("after="))); + expect(callAt(1).args).toContain("after=cursor-0"); + expect(callAt(2).args).toContain("after=cursor-1"); + assert.isFalse(viewed.truncated); + expect(viewed.files.map((file) => [file.path, file.state])).toEqual([ + ["src/file0.ts", "viewed"], + ["src/other0.ts", "unviewed"], + ["src/file1.ts", "viewed"], + ["src/other1.ts", "unviewed"], + ["src/file2.ts", "viewed"], + ["src/other2.ts", "unviewed"], + ]); + }), + ); + + it.effect("stops paging viewed files rather than following a change without end", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { + pullRequest: { + files: { + pageInfo: { hasNextPage: true, endCursor: "cursor" }, + nodes: [{ path: "src/file.ts", viewerViewedState: "VIEWED" }], + }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const viewed = yield* cli.getPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 5); + assert.isTrue(viewed.truncated); + assert.strictEqual(viewed.files.length, 5); + }), + ); + + it.effect("clears and restores a burst of files in one request", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + // @effect-diagnostics-next-line preferSchemaOverJson:off + output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_1" } } } })), + ), + ) + .mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + files: [ + { path: "src/a.ts", viewed: true }, + { path: "src/b.ts", viewed: false }, + ], + }); + + // One request to learn the pull request's node id, one for every press together. + assert.strictEqual(mockedExecute.mock.calls.length, 2); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const sent = JSON.parse(callAt(1).stdin ?? "") as { + query: string; + variables: Record; + }; + expect(sent.query).toContain("f0: markFileAsViewed"); + expect(sent.query).toContain("f1: unmarkFileAsViewed"); + expect(sent.variables).toEqual({ + pullRequestId: "PR_1", + path0: "src/a.ts", + path1: "src/b.ts", + }); + }), + ); + + it.effect("asks the host nothing when nothing was pressed", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + files: [], + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); }); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 2084a50d0206..73b9d29005dd 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -7,6 +7,7 @@ import { resolvePullRequestAuthorFilter, type PullRequestAction, type PullRequestActor, + type PullRequestFileViewed, type PullRequestInvolvement, type PullRequestListFilters, type PullRequestListState, @@ -30,10 +31,12 @@ import { ADD_REACTION_GRAPHQL_MUTATION, buildReviewSubmissionJson, buildReviewerRequestJson, + buildSetFilesViewedGraphQlMutation, decodeActorAvatarsJson, decodePullRequestActivityJson, decodePullRequestDetailJson, decodePullRequestFilesJson, + decodePullRequestFilesViewedJson, decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, @@ -53,6 +56,7 @@ import { decodeBaseComparisonJson, PULL_REQUEST_DETAIL_JSON_FIELDS, PULL_REQUEST_LIST_JSON_FIELDS, + PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, REMOVE_REACTION_GRAPHQL_MUTATION, @@ -263,6 +267,12 @@ const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; /** What the files API serves at most in one response, which is what one slice is made of. */ const DIFF_FILES_PAGE_SIZE = 100; +/** + * How many hundred-file pages of viewed state one read will walk. A point of the hourly GraphQL + * budget per page, against a change request nobody reviews in one sitting past the first few + * hundred files: beyond this the read stops and says it was cut short. + */ +const FILES_VIEWED_MAX_PAGES = 5; /** * Pages of review threads to follow before the conversation is reported as truncated. GitHub @@ -308,6 +318,12 @@ export interface GitHubPullRequestDiffSlice { readonly omittedFileStats?: ReadonlyArray; } +export interface GitHubPullRequestFilesViewed { + readonly files: ReadonlyArray; + /** GitHub had more files than the page budget below would read. */ + readonly truncated: boolean; +} + export class GitHubPullRequestCli extends Context.Service< GitHubPullRequestCli, { @@ -415,6 +431,30 @@ export class GitHubPullRequestCli extends Context.Service< GitHubPullRequestCliError >; + /** + * Which files of the pull request the signed-in account has cleared, and which of those have + * been pushed to since. Read apart from the patch because GitHub only reports it over GraphQL, + * and because the two answers go stale at completely different rates. + */ + readonly getPullRequestFilesViewed: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + /** + * Clears files, or puts them back, as one request. GitHub takes a single path per mutation, + * so a burst is batched with aliases into one document rather than one subprocess per press. + */ + readonly setPullRequestFilesViewed: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>; + }) => Effect.Effect; + readonly listReviewThreadComments: (input: { readonly cwd: string; readonly repository: string; @@ -912,14 +952,25 @@ export const make = Effect.gen(function* () { readonly host: string; readonly query: string; readonly variables: Readonly>; + /** What this write is expected to spend, for a batch that carries more than one mutation. */ + readonly estimatedCost?: number | undefined; }) => - github - .execute({ - cwd: input.cwd, - args: ["api", "graphql", "--hostname", input.host, "--input", "-"], - stdin: encodeGraphQlRequestJson({ query: input.query, variables: input.variables }), - }) - .pipe(Effect.asVoid); + graphQlBudget + // A write is counted against the hourly budget but never held back by it, so the reserve + // that pauses reads is measured against what has really been spent rather than against + // reads alone. It cannot fail here: the budget only refuses reads. + .query(input.host, input.query, { estimatedCost: input.estimatedCost ?? 1 }) + .pipe( + Effect.orElseSucceed(() => input.query), + Effect.flatMap((query) => + github.execute({ + cwd: input.cwd, + args: ["api", "graphql", "--hostname", input.host, "--input", "-"], + stdin: encodeGraphQlRequestJson({ query, variables: input.variables }), + }), + ), + Effect.asVoid, + ); /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ const graphqlRead = (input: { @@ -1763,6 +1814,59 @@ export const make = Effect.gen(function* () { variables: { threadId: input.threadId, body: input.body }, }), + getPullRequestFilesViewed: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + const read = ( + after: string | null, + collected: ReadonlyArray, + pagesLeft: number, + ): Effect.Effect => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestFilesViewed", + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ...(after === null + ? [] + : ([["-f", `after=${after}`]] as ReadonlyArray)), + ], + query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, + decode: decodePullRequestFilesViewedJson, + }).pipe( + Effect.flatMap((page) => { + const files = [...collected, ...page.files]; + if (page.nextCursor === null) { + return Effect.succeed({ files, truncated: false }); + } + // A change nobody could read in one sitting is not worth a point of budget a page: + // the boxes on screen still work, and the count says it is partial rather than lying. + return pagesLeft <= 1 + ? Effect.succeed({ files, truncated: true }) + : read(page.nextCursor, files, pagesLeft - 1); + }), + ); + return read(null, [], FILES_VIEWED_MAX_PAGES); + }, + + setPullRequestFilesViewed: (input) => { + const mutation = buildSetFilesViewedGraphQlMutation(input.files); + if (mutation === null) return Effect.void; + return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( + Effect.flatMap((pullRequestId) => + graphql({ + cwd: input.cwd, + host: input.host, + query: mutation.query, + variables: { pullRequestId, ...mutation.variables }, + estimatedCost: input.files.length, + }), + ), + ); + }, + setReviewThreadResolution: (input) => graphql({ cwd: input.cwd, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index cc097c30c2ed..ae057251fca9 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -33,6 +33,7 @@ const CAPABILITIES: PullRequestCapabilities = { updateMethods: ["merge", "rebase"], search: true, reactions: true, + viewedFiles: true, review: { inlineComment: true, reply: true, @@ -399,6 +400,12 @@ export const make = Effect.gen(function* () { getDiffFileContents: (input) => cli.getPullRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))), + getFilesViewed: (input) => + cli.getPullRequestFilesViewed(input).pipe(Effect.mapError(fail("getFilesViewed"))), + + setFilesViewed: (input) => + cli.setPullRequestFilesViewed(input).pipe(Effect.mapError(fail("setFilesViewed"))), + listReviewerCandidates: (input) => cli.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))), diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 644f3552cbc5..1ecba8c04224 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -8,6 +8,7 @@ import type { PullRequestChecksState, PullRequestCheck, PullRequestComment, + PullRequestFileViewed, PullRequestCommit, PullRequestInvolvement, PullRequestLabel, @@ -201,6 +202,12 @@ export interface ProviderDiffFileContents { readonly newContents: string; } +export interface ProviderFilesViewed { + readonly files: ReadonlyArray; + /** The host has more files than were read, so the ones missing here are not "unviewed". */ + readonly truncated: boolean; +} + export interface ProviderRepositoryRef { readonly cwd: string; /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */ @@ -355,6 +362,29 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + /** + * Which files the reader has already cleared. Only called when `capabilities.viewedFiles` is + * true, and read apart from the patch: a host that reports this at all reports it on a clock of + * its own, moving with every press rather than with every push. + */ + readonly getFilesViewed?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** + * Clears files, or puts them back. Only called when `capabilities.viewedFiles` is true. + * + * Takes several at once because that is how they are pressed. A provider whose host has no + * bulk form still owes one round trip for the batch rather than one per file, since the point + * of gathering them here is that the host is asked once. + */ + readonly setFilesViewed?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>; + }, + ) => Effect.Effect; + readonly runAction: ( input: ProviderRepositoryRef & { readonly number: number; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 84bd57dfa27b..987dba0d1bde 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3385,3 +3385,84 @@ it.effect("names the signed-in account in the detail, and says nothing where the assert.strictEqual(unnamed.viewer, undefined); }), ); + +it.effect("keeps the diff cached across a file being ticked off", () => + Effect.gen(function* () { + let diffReads = 0; + let viewedReads = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + viewedFiles: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getDiff: () => { + diffReads += 1; + return Effect.succeed({ patch: "@@", truncated: false, nextCursor: null }); + }, + getFilesViewed: () => { + viewedReads += 1; + return Effect.succeed({ + files: [{ path: "src/a.ts", state: "viewed" as const }], + truncated: false, + }); + }, + setFilesViewed: () => Effect.void, + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; + + yield* service.diff(reference); + yield* service.filesViewed(reference); + yield* service.setFilesViewed({ ...reference, files: [{ path: "src/a.ts", viewed: false }] }); + yield* service.diff(reference); + yield* service.filesViewed(reference); + + // The press forgets only the reader's own ticks: a diff of any size survives it. + assert.strictEqual(diffReads, 1); + assert.strictEqual(viewedReads, 2); + }), +); + +it.effect("refuses to track viewed files on a host that does not", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("gitlab", { + getFilesViewed: () => Effect.die("must not be called"), + setFilesViewed: () => Effect.die("must not be called"), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "group/project", number: 1 }; + + const read = yield* Effect.flip(service.filesViewed(reference)); + const write = yield* Effect.flip( + service.setFilesViewed({ ...reference, files: [{ path: "a.ts", viewed: true }] }), + ); + + assert.strictEqual(read._tag, "PullRequestOperationError"); + assert.strictEqual(write._tag, "PullRequestOperationError"); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index fc76a6501931..41b2a5bd61c5 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -22,6 +22,7 @@ import { type PullRequestDiffFileContentsResult, type PullRequestDiffStat, type PullRequestDiffInput, + type PullRequestFilesViewedResult, type PullRequestDiffResult, type PullRequestInvalidateInput, type PullRequestListEntry, @@ -37,6 +38,7 @@ import { type PullRequestReviewVerdict, type PullRequestReviewerCandidateList, type PullRequestReviewerRequestInput, + type PullRequestSetFilesViewedInput, type PullRequestSubmitReviewInput, type PullRequestThreadReplyInput, type PullRequestThreadResolutionInput, @@ -102,6 +104,12 @@ const DIFF_CACHE_TTL = Duration.seconds(60); const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10); /** Sized like the client's own stale time; a row's counts move only when somebody pushes. */ const LIST_STATS_CACHE_TTL = Duration.seconds(60); +/** + * Short, and with no stale window behind it: this is the reader's own bookkeeping, and the + * press that changes it is the same press the page is already showing optimistically. Held at + * all only so opening a change request on two devices costs one read. + */ +const FILES_VIEWED_CACHE_TTL = Duration.seconds(15); /** * How long a cache's last success may still be served while a fresh read runs behind it. * Bounded by how the page actually revalidates: clients re-read on mount and once a minute @@ -119,6 +127,7 @@ const LIST_CACHE_CAPACITY = 64; const LIST_STATS_CACHE_CAPACITY = 32; const DETAIL_CACHE_CAPACITY = 128; const DIFF_CACHE_CAPACITY = 128; +const FILES_VIEWED_CACHE_CAPACITY = 128; export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; @@ -144,6 +153,12 @@ export class PullRequestService extends Context.Service< readonly diffFileContents: ( input: PullRequestDiffFileContentsInput, ) => Effect.Effect; + readonly filesViewed: ( + input: PullRequestRef, + ) => Effect.Effect; + readonly setFilesViewed: ( + input: PullRequestSetFilesViewedInput, + ) => Effect.Effect; readonly runAction: (input: PullRequestActionInput) => Effect.Effect; readonly update: (input: PullRequestUpdateInput) => Effect.Effect; readonly comment: (input: PullRequestCommentInput) => Effect.Effect; @@ -450,6 +465,12 @@ function withRateLimitBackoff( ...(api.getDiffFileContents === undefined ? {} : { getDiffFileContents: wrap("getDiffFileContents", api.getDiffFileContents) }), + ...(api.getFilesViewed === undefined + ? {} + : { getFilesViewed: wrap("getFilesViewed", api.getFilesViewed) }), + ...(api.setFilesViewed === undefined + ? {} + : { setFilesViewed: interactive("setFilesViewed", api.setFilesViewed) }), runAction: interactive("runAction", api.runAction), ...(api.updateChangeRequest === undefined ? {} @@ -1297,6 +1318,51 @@ export const make = Effect.gen(function* () { }), ); + const filesViewedUncached = (input: PullRequestRef) => + requireProject(input).pipe( + Effect.flatMap((project) => { + const read = project.api.getFilesViewed; + return project.api.capabilities.viewedFiles === true && read + ? read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }).pipe(Effect.mapError(toPullRequestError("filesViewed"))) + : Effect.fail( + new PullRequestOperationError({ + operation: "filesViewed", + detail: "This host does not track which files a reader has seen.", + }), + ); + }), + ); + + const setFilesViewed: PullRequestService["Service"]["setFilesViewed"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + const write = project.api.setFilesViewed; + return project.api.capabilities.viewedFiles === true && write + ? write({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + files: input.files, + }).pipe(Effect.mapError(toPullRequestError("setFilesViewed"))) + : Effect.fail( + new PullRequestOperationError({ + operation: "setFilesViewed", + detail: "This host does not track which files a reader has seen.", + }), + ); + }), + // Deliberately not `invalidatedByMutation`: ticking a file off says nothing about the + // change request, and dropping a 300-file diff on every checkbox is the whole cost of + // the feature. Only this reader's own bookkeeping is forgotten. + Effect.tap(() => Effect.sync(() => bumpFilesViewedEpoch(input))), + ); + const runAction: PullRequestService["Service"]["runAction"] = (input) => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { @@ -1872,14 +1938,20 @@ 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 bumpRefEpoch = (ref: PullRequestRef) => { + const bumpEpoch = (epochs: Map, ref: PullRequestRef) => { const scope = refScope(ref); - if (!refEpochs.has(scope) && refEpochs.size >= REF_EPOCH_CAPACITY) { - const oldest = refEpochs.keys().next().value; - if (oldest !== undefined) refEpochs.delete(oldest); + if (!epochs.has(scope) && epochs.size >= REF_EPOCH_CAPACITY) { + const oldest = epochs.keys().next().value; + if (oldest !== undefined) epochs.delete(oldest); } - refEpochs.set(scope, ++epochCounter); + epochs.set(scope, ++epochCounter); }; + const bumpRefEpoch = (ref: PullRequestRef) => bumpEpoch(refEpochs, ref); + // Its own scope, so a press forgets the reader's ticks and nothing else. The read's key + // carries both epochs, which is what makes an ordinary refresh re-ask for these too. + const filesViewedEpochs = new Map(); + const filesViewedEpoch = (ref: PullRequestRef) => filesViewedEpochs.get(refScope(ref)) ?? 0; + const bumpFilesViewedEpoch = (ref: PullRequestRef) => bumpEpoch(filesViewedEpochs, ref); /** The positional filter slot of a cache key, back as the record `listUncached` takes. */ const filtersOfKey = ( @@ -2060,6 +2132,34 @@ export const make = Effect.gen(function* () { return staleDiff(key, Cache.get(diffCache, key)); }; + const filesViewedCache = yield* Cache.makeWith( + (key: string) => { + const [, , projectId, repository, number] = JSON.parse(key) as [ + number, + number, + string, + string, + number, + ]; + return filesViewedUncached({ projectId, repository, number } as PullRequestRef); + }, + { + capacity: FILES_VIEWED_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? FILES_VIEWED_CACHE_TTL : Duration.zero), + }, + ); + const filesViewed: PullRequestService["Service"]["filesViewed"] = (input) => + Cache.get( + filesViewedCache, + JSON.stringify([ + refEpoch(input), + filesViewedEpoch(input), + input.projectId, + input.repository, + input.number, + ]), + ); + const listStatsCache = yield* Cache.makeWith( (key: string) => { const [, refs] = JSON.parse(key) as [number, ReadonlyArray<[string, string, number]>]; @@ -2130,6 +2230,8 @@ export const make = Effect.gen(function* () { threadComments, diff, diffFileContents, + filesViewed, + setFilesViewed, runAction: invalidatedByMutation(runAction), update: invalidatedByMutation(update), comment: invalidatedByMutation(comment), diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index f372ac3000a0..f20f20d5a265 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -4,10 +4,12 @@ import { describe, expect, it } from "vite-plus/test"; import { buildReviewSubmissionJson, buildReviewerRequestJson, + buildSetFilesViewedGraphQlMutation, decodeBaseComparisonJson, decodePullRequestActivityJson, decodePullRequestDetailJson, decodePullRequestFilesJson, + decodePullRequestFilesViewedJson, decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, @@ -1361,3 +1363,97 @@ describe("how far a branch trails its base", () => { expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false); }); }); + +describe("decodePullRequestFilesViewedJson", () => { + const page = ( + nodes: ReadonlyArray, + pageInfo: { hasNextPage: boolean; endCursor: string | null }, + ) => + JSON.stringify({ + data: { repository: { pullRequest: { files: { pageInfo, nodes } } } }, + }); + + it("reads each file's state and where the next page carries on", () => { + const decoded = decodePullRequestFilesViewedJson( + page( + [ + { path: "src/a.ts", viewerViewedState: "VIEWED" }, + { path: "src/b.ts", viewerViewedState: "UNVIEWED" }, + { path: "src/c.ts", viewerViewedState: "DISMISSED" }, + ], + { hasNextPage: true, endCursor: "cursor-2" }, + ), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect(decoded.success).toEqual({ + files: [ + { path: "src/a.ts", state: "viewed" }, + { path: "src/b.ts", state: "unviewed" }, + { path: "src/c.ts", state: "dismissed" }, + ], + nextCursor: "cursor-2", + }); + }); + + it("treats a state it has never heard of as unread rather than failing the page", () => { + const decoded = decodePullRequestFilesViewedJson( + page([{ path: "src/a.ts", viewerViewedState: "SOMETHING_NEW" }], { + hasNextPage: false, + endCursor: null, + }), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect(decoded.success).toEqual({ + files: [{ path: "src/a.ts", state: "unviewed" }], + nextCursor: null, + }); + }); + + it("answers empty for a pull request the host has nothing to say about", () => { + const decoded = decodePullRequestFilesViewedJson( + JSON.stringify({ data: { repository: { pullRequest: null } } }), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect(decoded.success).toEqual({ files: [], nextCursor: null }); + }); +}); + +describe("buildSetFilesViewedGraphQlMutation", () => { + it("asks for nothing when nothing was pressed", () => { + expect(buildSetFilesViewedGraphQlMutation([])).toBeNull(); + }); + + it("clears and restores in one document, each file under its own alias", () => { + const mutation = buildSetFilesViewedGraphQlMutation([ + { path: "src/a.ts", viewed: true }, + { path: "src/b.ts", viewed: false }, + ]); + expect(mutation).not.toBeNull(); + if (mutation === null) return; + expect(mutation.query).toContain( + "mutation($pullRequestId: ID!, $path0: String!, $path1: String!)", + ); + expect(mutation.query).toContain( + "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path0 })", + ); + expect(mutation.query).toContain( + "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path1 })", + ); + expect(mutation.variables).toEqual({ path0: "src/a.ts", path1: "src/b.ts" }); + }); + + it("keeps a path out of the document, so one cannot be read as part of it", () => { + const mutation = buildSetFilesViewedGraphQlMutation([ + { path: '") { __typename } evil: markFileAsViewed(input: { path: "x', viewed: true }, + ]); + expect(mutation).not.toBeNull(); + if (mutation === null) return; + expect(mutation.query).not.toContain("evil"); + expect(mutation.variables.path0).toBe( + '") { __typename } evil: markFileAsViewed(input: { path: "x', + ); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 6ec17ea111b3..773b3aa6700b 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -9,6 +9,7 @@ import type { PullRequestChecksState, PullRequestComment, PullRequestCommit, + PullRequestFileViewedState, PullRequestLabel, PullRequestMergeCapabilities, PullRequestOmittedFileStat, @@ -2239,3 +2240,122 @@ export function decodePullRequestFilesJson( omittedFileStats, }); } + +/** + * Which files of a pull request the signed-in account has cleared. + * + * GraphQL only — the REST files endpoint the patch is read from carries no viewed state at all, + * so this is a second read rather than a wider version of the first. One page of a hundred files + * costs a single point of the hourly budget, which is why it can ride the diff's own refresh + * without being noticed. + */ +export const PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + files(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { path viewerViewedState } + } + } + } +}`; + +const RawPullRequestFilesViewedSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + pullRequest: Schema.NullOr( + Schema.Struct({ + files: Schema.Struct({ + pageInfo: Schema.Struct({ + hasNextPage: Schema.Boolean, + endCursor: Schema.NullOr(Schema.String), + }), + nodes: Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + path: Schema.String, + // Decoded as a plain string and narrowed below: a GitHub release that adds + // a fourth state must not fail the whole page. + viewerViewedState: Schema.String, + }), + ), + ), + ), + }), + }), + ), + }), + ), + }), +}); + +const decodePullRequestFilesViewed = decodeJsonResult(RawPullRequestFilesViewedSchema); + +export interface GitHubPullRequestFilesViewedPage { + readonly files: ReadonlyArray<{ + readonly path: string; + readonly state: PullRequestFileViewedState; + }>; + /** Where the next page carries on, or null once the host has no more to give. */ + readonly nextCursor: string | null; +} + +/** Anything this host does not name is treated as unread, which is the state that asks for least. */ +function toFileViewedState(raw: string): PullRequestFileViewedState { + switch (raw.trim().toUpperCase()) { + case "VIEWED": + return "viewed"; + case "DISMISSED": + return "dismissed"; + default: + return "unviewed"; + } +} + +export function decodePullRequestFilesViewedJson( + raw: string, +): Result.Result { + const decoded = decodePullRequestFilesViewed(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const files = decoded.success.data.repository?.pullRequest?.files; + if (files === undefined) return Result.succeed({ files: [], nextCursor: null }); + return Result.succeed({ + files: (files.nodes ?? []).flatMap((node) => + node === null || node.path.length === 0 + ? [] + : [{ path: node.path, state: toFileViewedState(node.viewerViewedState) }], + ), + nextCursor: files.pageInfo.hasNextPage ? files.pageInfo.endCursor : null, + }); +} + +/** + * One document that clears and restores as many files as the reader ticked, rather than one + * request each. + * + * GitHub has no bulk form of either mutation — `markFileAsViewed` and `unmarkFileAsViewed` take a + * single path — so the batching is done with aliases. Top-level mutation fields run in the order + * they are written, so the last word about a path is the one that sticks, and the whole burst + * costs one HTTP round trip and one subprocess instead of one of each per press. + * + * Paths travel as variables rather than inside the document: they are the host's own strings, but + * a path is data and a document is not, and building one out of the other is how injection starts. + */ +export function buildSetFilesViewedGraphQlMutation( + files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>, +): { readonly query: string; readonly variables: Readonly> } | null { + if (files.length === 0) return null; + const parameters = files.map((_, index) => `$path${index}: String!`).join(", "); + const fields = files + .map( + (file, index) => + ` f${index}: ${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: $path${index} }) { clientMutationId }`, + ) + .join("\n"); + return { + query: `mutation($pullRequestId: ID!, ${parameters}) {\n${fields}\n}`, + variables: Object.fromEntries(files.map((file, index) => [`path${index}`, file.path])), + }; +} diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts index a166bf0dbbaf..b85371c810e2 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts @@ -186,4 +186,31 @@ describe("GitHub GraphQL budget", () => { expect(yield* budget.query("github.com", mutation)).toBe(mutation); }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), ); + + it.effect("charges a write for the batch it carries, since it cannot report its own cost", () => + Effect.gen(function* () { + yield* TestClock.setTime(BEFORE_RESET); + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + // Twenty points above the reserve, which is exactly what the mutation below spends. + yield* budget.observe("github.com", rateLimit(520)); + + yield* budget.query("github.com", "mutation { f0: markFileAsViewed { id } }", { + estimatedCost: 20, + }); + + const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); + expect(error).toMatchObject({ _tag: "SourceControlRateLimitPausedError" }); + }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), + ); + + it.effect("lets a write through even with nothing left, rather than holding a press back", () => + Effect.gen(function* () { + yield* TestClock.setTime(BEFORE_RESET); + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + yield* budget.observe("github.com", rateLimit(0)); + + const mutation = "mutation { f0: markFileAsViewed { id } }"; + expect(yield* budget.query("github.com", mutation, { estimatedCost: 40 })).toBe(mutation); + }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), + ); }); diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.ts b/apps/server/src/sourceControl/githubGraphQlBudget.ts index 9c43de8e0586..8745d691bc84 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.ts @@ -23,7 +23,14 @@ export class GitHubGraphQlBudget extends Context.Service< readonly query: ( host: string, document: string, - options?: { readonly allowReserve: boolean }, + options?: { + readonly allowReserve?: boolean | undefined; + /** + * What a write is expected to spend, for the debit above. Ignored for a read, which + * reports its own cost. Defaults to one point, which is a mutation's floor. + */ + readonly estimatedCost?: number | undefined; + }, ) => Effect.Effect; readonly observe: (host: string, raw: string) => Effect.Effect; } @@ -82,8 +89,27 @@ export const make = Effect.gen(function* () { const query: GitHubGraphQlBudget["Service"]["query"] = Effect.fn("GitHubGraphQlBudget.query")( function* (host, document, options) { - if (!isReadOperation(document)) return document; const now = yield* Clock.currentTimeMillis; + // A write spends the same hourly points a read does, and `rateLimit` is a field of Query + // alone — so a mutation cannot report its own cost and is debited from the held snapshot + // instead. Never paused, only counted: a mutation is somebody pressing something, and + // holding it back to protect a read nobody has asked for yet is the wrong trade. The + // estimate only has to last until the next read, whose answer replaces the snapshot with + // the host's own number. + if (!isReadOperation(document)) { + yield* Ref.update(snapshots, (current) => { + const key = hostKey(host); + const snapshot = current.get(key); + if (snapshot === undefined || snapshot.resetAtMs <= now) return current; + const next = new Map(current); + next.set(key, { + ...snapshot, + remaining: Math.max(0, snapshot.remaining - Math.max(1, options?.estimatedCost ?? 1)), + }); + return next; + }); + return document; + } const retryAt = yield* Ref.modify(snapshots, (current) => { const key = hostKey(host); const snapshot = current.get(key); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c5b7e50a8704..350b8bf6f7c9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1693,6 +1693,16 @@ const makeWsRpcLayer = ( pullRequests.diffFileContents(input), { "rpc.aggregate": "pull-requests" }, ), + [WS_METHODS.pullRequestsFilesViewed]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsFilesViewed, pullRequests.filesViewed(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsSetFilesViewed]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsSetFilesViewed, + pullRequests.setFilesViewed(input), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.pullRequestsRunAction]: (input) => observeRpcEffect(WS_METHODS.pullRequestsRunAction, pullRequests.runAction(input), { "rpc.aggregate": "pull-requests", diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index b0e00d57cc61..fa9e5ed97026 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -58,6 +58,7 @@ import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { DropdownMenu, @@ -73,9 +74,11 @@ import { PullRequestReviewBar } from "./PullRequestReviewBar"; import { isFileDiffCollapsed, isLineInFileDiff, + toggleFileDiffFoldForViewed, type DiffFoldOverride, } from "./pullRequestDiff.logic"; import { PullRequestDiffStat, PullRequestMetaLine } from "./pullRequestPresentation"; +import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed"; import { nextPendingReviewCommentId, pullRequestReviewKey, @@ -396,6 +399,17 @@ export function PullRequestCodeTab({ ), [parsedSlices], ); + const filePaths = useMemo(() => files.map((file) => resolveFileDiffPath(file)), [files]); + // Offered under a commit scope as well as from the whole change, because reading a change one + // commit at a time is what the scope is for. The tick itself stays the host's: it is kept + // against the change request, so clearing a file here clears it everywhere. + const filesViewed = usePullRequestFilesViewed({ + environmentId, + reference, + enabled: detail.capabilities.viewedFiles === true, + paths: filePaths, + }); + const { setViewed } = filesViewed; const nextCursor = loadedSlices.at(-1)?.nextCursor ?? null; // What a slice withheld: the host declining to inline part of it, or a patch the viewer could // not structure and so dropped. Neither says anything about there being more to fetch. @@ -587,6 +601,19 @@ export function PullRequestCodeTab({ [], ); + // The tick and the fold are one gesture: clearing a file puts it away, un-clearing brings it + // back. Folding is still held as the reader's difference from the toolbar's default rather + // than derived from what has been ticked, so folding everything ticks nothing off. + const setFileViewed = useCallback( + (fileKey: string, path: string, viewed: boolean) => { + setViewed(path, viewed); + setToggledFiles((current) => + toggleFileDiffFoldForViewed(fileKey, viewed, foldOverride, current), + ); + }, + [foldOverride, setViewed], + ); + const toggleAllFiles = () => { // Held as an override of the default rather than as the file keys on screen: a diff that is // still paging would otherwise bring its next slice in folded, moments after the reader @@ -722,19 +749,51 @@ export function PullRequestCodeTab({ additions += hunk.additionLines; deletions += hunk.deletionLines; } + const path = resolveFileDiffPath(item.fileDiff); if (additions === 0 && deletions === 0) { - const withheld = omittedFileStats.get(resolveFileDiffPath(item.fileDiff)); + const withheld = omittedFileStats.get(path); if (withheld) ({ additions, deletions } = withheld); } - return ( + const stat = ( ); + if (!filesViewed.enabled) return stat; + const viewed = filesViewed.isViewed(path); + const stale = filesViewed.isStale(path); + return ( + + {stat} + {/* The header itself folds the file, so the tick has to keep its press to itself. */} + + + ); }, - [omittedFileStats], + [filesViewed, omittedFileStats, setFileViewed], ); const diffViewOptions = useMemo( @@ -1058,6 +1117,11 @@ export function PullRequestCodeTab({ {files.length} {files.length === 1 ? "file" : "files"} {nextCursor === null ? "" : "+"} + {filesViewed.enabled && files.length > 0 ? ( + + {filesViewed.viewedCount} / {files.length} viewed + + ) : null} {withheldContent ? ( }> diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts index b39cfd9ff1b5..5a5ae8149097 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts @@ -1,7 +1,11 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import { describe, expect, it } from "vite-plus/test"; -import { isFileDiffCollapsed, isLineInFileDiff } from "./pullRequestDiff.logic"; +import { + isFileDiffCollapsed, + isLineInFileDiff, + toggleFileDiffFoldForViewed, +} from "./pullRequestDiff.logic"; /** Only the hunk ranges matter here; the viewer fills the rest in when it renders. */ function fileWithHunks( @@ -79,3 +83,31 @@ describe("isFileDiffCollapsed", () => { expect(isFileDiffCollapsed("a.ts", "folded", new Set(["a.ts"]))).toBe(false); }); }); + +describe("toggleFileDiffFoldForViewed", () => { + it("puts a file away when it is ticked off", () => { + // Files start folded, so one the reader had opened is the case that has somewhere to go. + const opened = new Set(["a.ts"]); + expect([...toggleFileDiffFoldForViewed("a.ts", true, null, opened)]).toEqual([]); + }); + + it("brings a file back when the tick is taken off", () => { + expect([...toggleFileDiffFoldForViewed("a.ts", false, null, new Set())]).toEqual(["a.ts"]); + }); + + it("leaves the fold alone when it already says what the tick does", () => { + const folded = new Set(); + expect(toggleFileDiffFoldForViewed("a.ts", true, null, folded)).toBe(folded); + }); + + it("moves against whatever the toolbar last asked for", () => { + // Everything is open, so ticking a file off has to fold that one against the default. + expect([...toggleFileDiffFoldForViewed("a.ts", true, "expanded", new Set())]).toEqual(["a.ts"]); + expect(toggleFileDiffFoldForViewed("a.ts", false, "expanded", new Set()).size).toBe(0); + }); + + it("touches only the file that was ticked", () => { + const toggled = new Set(["a.ts", "b.ts"]); + expect([...toggleFileDiffFoldForViewed("a.ts", true, null, toggled)]).toEqual(["b.ts"]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts index b3c19c4fe9c2..8a6061c4e5c6 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts @@ -42,3 +42,24 @@ export function isFileDiffCollapsed( const foldedByDefault = foldOverride !== "expanded"; return toggledFileKeys.has(fileKey) ? !foldedByDefault : foldedByDefault; } + +/** + * The reader's fold choices after a file was ticked off, or put back. + * + * Clearing a file puts it away and un-clearing brings it back, so the tick moves the fold as if + * the reader had pressed the chevron themselves — which keeps folding a difference from what the + * toolbar last asked, and so keeps "collapse all" from ticking anything off. + */ +export function toggleFileDiffFoldForViewed( + fileKey: string, + viewed: boolean, + foldOverride: DiffFoldOverride, + toggledFileKeys: ReadonlySet, +): ReadonlySet { + if (isFileDiffCollapsed(fileKey, foldOverride, toggledFileKeys) === viewed) + return toggledFileKeys; + const next = new Set(toggledFileKeys); + if (next.has(fileKey)) next.delete(fileKey); + else next.add(fileKey); + return next; +} diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts new file mode 100644 index 000000000000..90c3d71f9b04 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + countViewedFiles, + isFileViewed, + isStaleViewedState, + settleFileViewedOverlay, + toFileViewedBatch, + toFileViewedStates, + type FileViewedOverlay, +} from "./pullRequestFilesViewed.logic"; + +const NO_OVERLAY: FileViewedOverlay = new Map(); +const NOTHING_PENDING: ReadonlySet = new Set(); + +const states = toFileViewedStates({ + files: [ + { path: "a.ts", state: "viewed" }, + { path: "b.ts", state: "unviewed" }, + { path: "c.ts", state: "dismissed" }, + ], + truncated: false, +}); + +describe("isFileViewed", () => { + it("follows the host for a file the reader has not pressed", () => { + expect(isFileViewed("a.ts", states, NO_OVERLAY)).toBe(true); + expect(isFileViewed("b.ts", states, NO_OVERLAY)).toBe(false); + }); + + it("reads a file pushed to since it was cleared as unread", () => { + expect(isFileViewed("c.ts", states, NO_OVERLAY)).toBe(false); + expect(isStaleViewedState(states?.get("c.ts"))).toBe(true); + expect(isStaleViewedState(states?.get("a.ts"))).toBe(false); + }); + + it("shows the press ahead of the host's answer", () => { + expect(isFileViewed("b.ts", states, new Map([["b.ts", true]]))).toBe(true); + expect(isFileViewed("a.ts", states, new Map([["a.ts", false]]))).toBe(false); + }); + + it("answers a file the host has said nothing about, before its answer arrives", () => { + expect(isFileViewed("z.ts", null, NO_OVERLAY)).toBe(false); + expect(isFileViewed("z.ts", null, new Map([["z.ts", true]]))).toBe(true); + }); +}); + +describe("countViewedFiles", () => { + it("counts only the files on screen, presses included", () => { + expect(countViewedFiles(["a.ts", "b.ts", "c.ts"], states, NO_OVERLAY)).toBe(1); + expect(countViewedFiles(["a.ts", "b.ts", "c.ts"], states, new Map([["b.ts", true]]))).toBe(2); + // A file the host knows about but the diff has not paged in yet is not counted. + expect(countViewedFiles(["b.ts"], states, NO_OVERLAY)).toBe(0); + }); +}); + +describe("settleFileViewedOverlay", () => { + it("drops a press the host has caught up on", () => { + const settled = settleFileViewedOverlay(new Map([["a.ts", true]]), states, NOTHING_PENDING); + expect(settled.size).toBe(0); + }); + + it("keeps a press the host still disagrees with", () => { + const overlay = new Map([["b.ts", true]]); + expect(settleFileViewedOverlay(overlay, states, NOTHING_PENDING)).toBe(overlay); + }); + + it("keeps a press the host cannot have heard yet", () => { + // An answer already on its way when the file was un-ticked would otherwise put the tick back. + const overlay = new Map([["a.ts", false]]); + const settled = settleFileViewedOverlay(overlay, states, new Set(["a.ts"])); + expect(settled.get("a.ts")).toBe(false); + }); + + it("settles a file pushed to since it was cleared against un-ticking it", () => { + const settled = settleFileViewedOverlay(new Map([["c.ts", false]]), states, NOTHING_PENDING); + expect(settled.size).toBe(0); + }); + + it("holds everything until the host has answered at all", () => { + const overlay = new Map([["a.ts", true]]); + expect(settleFileViewedOverlay(overlay, null, NOTHING_PENDING)).toBe(overlay); + }); +}); + +describe("toFileViewedBatch", () => { + it("carries both directions in one batch", () => { + expect( + toFileViewedBatch( + new Map([ + ["a.ts", false], + ["b.ts", true], + ]), + ), + ).toEqual([ + { path: "a.ts", viewed: false }, + { path: "b.ts", viewed: true }, + ]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts new file mode 100644 index 000000000000..2bc011297a53 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts @@ -0,0 +1,82 @@ +import type { PullRequestFileViewedState, PullRequestFilesViewedResult } from "@t3tools/contracts"; + +/** What the host last said about each file, by path. Absent means the host said nothing. */ +export type FileViewedStates = ReadonlyMap; + +/** Presses the host has not confirmed yet, by path. */ +export type FileViewedOverlay = ReadonlyMap; + +export function toFileViewedStates( + result: PullRequestFilesViewedResult | null, +): FileViewedStates | null { + if (result === null) return null; + return new Map(result.files.map((file) => [file.path, file.state])); +} + +/** + * Whether a file counts as seen. + * + * `dismissed` is the host saying it has been pushed to since the reader cleared it, which reads + * as unseen — the point of the tick is that the code behind it has been looked at, and it is not + * the same code any more. + */ +export function isViewedState(state: PullRequestFileViewedState | undefined): boolean { + return state === "viewed"; +} + +/** Whether the file was cleared and has since moved, which the header says out loud. */ +export function isStaleViewedState(state: PullRequestFileViewedState | undefined): boolean { + return state === "dismissed"; +} + +/** The press the reader made if it has not landed, and the host's answer otherwise. */ +export function isFileViewed( + path: string, + states: FileViewedStates | null, + overlay: FileViewedOverlay, +): boolean { + const pressed = overlay.get(path); + return pressed ?? isViewedState(states?.get(path)); +} + +export function countViewedFiles( + paths: ReadonlyArray, + states: FileViewedStates | null, + overlay: FileViewedOverlay, +): number { + return paths.reduce( + (total, path) => (isFileViewed(path, states, overlay) ? total + 1 : total), + 0, + ); +} + +/** + * The overlay with everything the host has caught up on removed. + * + * A press is held locally until the host's own answer agrees with it, rather than cleared when + * the request succeeds: the read that follows a write is a separate round trip, and dropping the + * press in between would flash the checkbox back for as long as that took. + * + * `unsettled` are the paths whose press the host cannot have heard yet, which an answer that was + * already on its way when they were pressed must not be allowed to overrule. + */ +export function settleFileViewedOverlay( + overlay: FileViewedOverlay, + states: FileViewedStates | null, + unsettled: ReadonlySet, +): FileViewedOverlay { + if (states === null || overlay.size === 0) return overlay; + const next = new Map(overlay); + for (const [path, pressed] of overlay) { + if (unsettled.has(path)) continue; + if (isViewedState(states.get(path)) === pressed) next.delete(path); + } + return next.size === overlay.size ? overlay : next; +} + +/** The presses in an overlay as the batch the host is told about. */ +export function toFileViewedBatch( + overlay: FileViewedOverlay, +): ReadonlyArray<{ readonly path: string; readonly viewed: boolean }> { + return [...overlay].map(([path, viewed]) => ({ path, viewed })); +} diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts new file mode 100644 index 000000000000..32d6934a57ec --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -0,0 +1,147 @@ +import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { toastManager } from "../ui/toast"; +import { + countViewedFiles, + isFileViewed, + isStaleViewedState, + settleFileViewedOverlay, + toFileViewedBatch, + toFileViewedStates, + type FileViewedOverlay, +} from "./pullRequestFilesViewed.logic"; + +/** + * How long presses gather before the host is told. Long enough that ticking down a file list + * costs one request rather than one per file, short enough that a reader who ticks one file and + * closes the tab has already been recorded. + */ +const FLUSH_DELAY_MS = 400; + +const NO_OVERLAY: FileViewedOverlay = new Map(); +const NO_PATHS: ReadonlySet = new Set(); + +export interface PullRequestFilesViewedView { + /** Whether the host tracks this at all, which is what hides the whole control. */ + readonly enabled: boolean; + readonly isViewed: (path: string) => boolean; + /** The host says this file has been pushed to since it was cleared. */ + readonly isStale: (path: string) => boolean; + readonly setViewed: (path: string, viewed: boolean) => void; + /** How many of the files on screen are ticked off. */ + readonly viewedCount: number; +} + +/** + * Which files this reader has already cleared, as the host records it. + * + * The state lives on the host rather than here so a review carried on from another machine, or + * from the host's own web UI, picks up where it was left. Presses show immediately and are held + * over the host's answer until it agrees with them, so the checkbox never waits on a round trip. + */ +export function usePullRequestFilesViewed(options: { + readonly environmentId: EnvironmentId; + readonly reference: PullRequestRef; + readonly enabled: boolean; + /** The paths on screen, which is what the counter counts. */ + readonly paths: ReadonlyArray; +}): PullRequestFilesViewedView { + const { environmentId, reference, enabled, paths } = options; + const query = useEnvironmentQuery( + enabled ? pullRequestEnvironment.filesViewed({ environmentId, input: reference }) : null, + ); + const refresh = query.refresh; + const states = useMemo(() => toFileViewedStates(query.data), [query.data]); + const [overlay, setOverlay] = useState(NO_OVERLAY); + const setFilesViewed = useAtomCommand(pullRequestEnvironment.setFilesViewed); + + // Presses waiting for the next flush, and the ones a request is already carrying. Both are + // refs rather than state: nothing on screen reads them, and the flush must see the latest. + const queued = useRef>(new Map()); + const inFlight = useRef>(NO_PATHS); + const flushTimer = useRef | null>(null); + + const referenceKey = `${reference.projectId} ${reference.repository} ${reference.number}`; + // Everything held here is about one change request, so switching away drops it rather than + // letting a press meant for one land on another. + useEffect(() => { + queued.current = new Map(); + inFlight.current = NO_PATHS; + setOverlay(NO_OVERLAY); + }, [referenceKey]); + + useEffect(() => { + setOverlay((current) => + settleFileViewedOverlay( + current, + states, + new Set([...queued.current.keys(), ...inFlight.current]), + ), + ); + }, [states]); + + const flush = useCallback(() => { + flushTimer.current = null; + const batch = toFileViewedBatch(queued.current); + if (batch.length === 0) return; + queued.current = new Map(); + const sent = new Set(batch.map((file) => file.path)); + inFlight.current = sent; + void setFilesViewed({ environmentId, input: { ...reference, files: batch } }).then((result) => { + inFlight.current = NO_PATHS; + if (result._tag === "Failure") { + // The host never heard these, so the ticks go back to whatever it last said. + setOverlay((current) => { + const next = new Map(current); + for (const path of sent) next.delete(path); + return next; + }); + toastManager.add({ type: "error", title: "Could not update viewed files" }); + return; + } + refresh(); + }); + }, [environmentId, reference, refresh, setFilesViewed]); + + // Read through a ref rather than closed over: `setViewed` is handed to every file header the + // viewer draws, and a new identity per render would rebuild all of them. + const flushRef = useRef(flush); + flushRef.current = flush; + + // A tab closed mid-gather still records what was pressed. + useEffect( + () => () => { + if (flushTimer.current === null) return; + clearTimeout(flushTimer.current); + flushRef.current(); + }, + [], + ); + + const setViewed = useCallback((path: string, viewed: boolean) => { + setOverlay((current) => new Map(current).set(path, viewed)); + queued.current.set(path, viewed); + if (flushTimer.current !== null) clearTimeout(flushTimer.current); + flushTimer.current = setTimeout(() => flushRef.current(), FLUSH_DELAY_MS); + }, []); + + const isViewed = useCallback( + (path: string) => isFileViewed(path, states, overlay), + [overlay, states], + ); + const isStale = useCallback( + (path: string) => !overlay.has(path) && isStaleViewedState(states?.get(path)), + [overlay, states], + ); + const viewedCount = useMemo( + () => countViewedFiles(paths, states, overlay), + [overlay, paths, states], + ); + + return { enabled, isViewed, isStale, setViewed, viewedCount }; +} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 916536bbe736..1bf50e0cd1a1 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -53,6 +53,21 @@ T3 Code works with the platforms your team already uses: - Works on GitHub, GitLab, and Bitbucket. Azure DevOps takes a new title and description; its comments stay read-only here, as they already were +**Keep your place in a long review** + +- Tick a file off in the **Code** tab once you have read it. The file collapses, and the toolbar + keeps a running count of how many files you have cleared +- Untick it to open the file back up +- Your ticks are stored with the pull request itself, so a review you start on one machine picks up + where you left it on the next, and in your browser too +- If a file is pushed to after you cleared it, it comes back marked **Changed** so you know to look + again +- GitHub only. GitLab, Bitbucket, and Azure DevOps do not keep this, so the checkbox is not shown + there +- Scope the **Code** tab to a single commit and the checkboxes stay, so you can read a change one + commit at a time. A tick belongs to the pull request, not to the commit, so a file you clear + there is cleared everywhere + ### Know Your Setup at a Glance The **Source Control settings** page shows you exactly what's connected: diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index d4830fa197d4..33d9b528a699 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -106,6 +106,31 @@ export function createPullRequestEnvironmentAtoms( ]), }, }), + /** + * Which files this reader has already cleared, apart from the diff: the answer moves with + * every checkbox rather than with every push, and a patch of a few hundred files must not + * be re-fetched to learn that one box was ticked. + */ + filesViewed: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:files-viewed", + tag: WS_METHODS.pullRequestsFilesViewed, + staleTimeMs: 15_000, + }), + /** + * One request per batch of presses, and one in flight per change request: the host applies + * these in order, and a reader ticking down a file list faster than the round trip would + * otherwise race their own presses. + */ + setFilesViewed: createEnvironmentRpcCommand(runtime, { + label: "environment-data:pull-requests:set-files-viewed", + tag: WS_METHODS.pullRequestsSetFilesViewed, + scheduler: commandScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId, input }) => + JSON.stringify([environmentId, input.projectId, input.repository, input.number]), + }, + }), runAction: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:run-action", tag: WS_METHODS.pullRequestsRunAction, diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index a49868937844..86a8927d4461 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -384,6 +384,16 @@ export const PullRequestCapabilities = Schema.Struct({ * what every server before this field was. */ reactions: Schema.optional(Schema.Boolean), + /** + * A file can be marked as read by the person reading it, and the mark taken back. Optional for + * the same reason as `reactions`: a server that says nothing about it has none, which is what + * every server before this field was. + * + * True on GitHub alone so far. The others expose no equivalent, and a checkbox whose mark is + * forgotten the moment the tab closes is worse than no checkbox — it looks like the one beside + * it and keeps none of its promises. + */ + viewedFiles: Schema.optional(Schema.Boolean), review: PullRequestReviewCapabilities, reviewers: PullRequestReviewerCapabilities, /** @@ -800,6 +810,59 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({ }); export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileContentsResult.Type; +/** + * Where one file of a change request stands with the person reading it. + * + * `dismissed` is the state that earns this its own read: the file was cleared, and has since been + * pushed to. It is not `viewed` — the reader has not seen what is there now — and it is not + * `unviewed` either, because saying so would lose the one thing worth telling them, which is that + * this file and not the other forty is the one that moved. + */ +export const PullRequestFileViewedState = Schema.Literals(["unviewed", "viewed", "dismissed"]); +export type PullRequestFileViewedState = typeof PullRequestFileViewedState.Type; + +export const PullRequestFileViewed = Schema.Struct({ + path: TrimmedNonEmptyString, + state: PullRequestFileViewedState, +}); +export type PullRequestFileViewed = typeof PullRequestFileViewed.Type; + +/** + * Which files of a change request the reader has cleared, read apart from the diff itself. + * + * Its own read rather than a field on the patch, for the same reason the listing's line counts + * are their own: the two move on entirely different clocks. A patch changes when somebody pushes, + * and is cached by the minute; this changes on every press of the checkbox. Carrying it on the + * diff would mean either forgetting a three-hundred-file patch each time a box is ticked, or + * showing a reader their own last press as stale. + */ +export const PullRequestFilesViewedResult = Schema.Struct({ + /** Only the files the host reported a state for. A file missing from this list is unviewed. */ + files: Schema.Array(PullRequestFileViewed), + /** + * The host had more files than were read. The checkbox still works on everything on screen; + * the count beside it is the one thing that cannot be trusted to be whole, and says so. + */ + truncated: Schema.Boolean, +}); +export type PullRequestFilesViewedResult = typeof PullRequestFilesViewedResult.Type; + +/** + * Files to clear, or to put back. Several at once because a reader working down a diff ticks + * boxes far faster than a host answers: the surface gathers a burst into one request rather than + * opening a subprocess per press. + */ +export const PullRequestSetFilesViewedInput = Schema.Struct({ + ...PullRequestRef.fields, + files: Schema.Array( + Schema.Struct({ + path: TrimmedNonEmptyString, + viewed: Schema.Boolean, + }), + ), +}); +export type PullRequestSetFilesViewedInput = typeof PullRequestSetFilesViewedInput.Type; + export const PullRequestActionInput = Schema.Struct({ ...PullRequestRef.fields, action: PullRequestAction, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 51c65f50e1a2..af1b4ba2a1ac 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -75,6 +75,7 @@ import { PullRequestDetail, PullRequestDiffFileContentsInput, PullRequestDiffFileContentsResult, + PullRequestFilesViewedResult, PullRequestInvalidateInput, PullRequestListInput, PullRequestListResult, @@ -85,6 +86,7 @@ import { PullRequestRef, PullRequestReviewerCandidateList, PullRequestReviewerRequestInput, + PullRequestSetFilesViewedInput, PullRequestSubmitReviewInput, PullRequestThreadCommentsInput, PullRequestThreadCommentsResult, @@ -285,6 +287,8 @@ export const WS_METHODS = { pullRequestsActivity: "pullRequests.activity", pullRequestsThreadComments: "pullRequests.threadComments", pullRequestsDiffFileContents: "pullRequests.diffFileContents", + pullRequestsFilesViewed: "pullRequests.filesViewed", + pullRequestsSetFilesViewed: "pullRequests.setFilesViewed", pullRequestsRunAction: "pullRequests.runAction", pullRequestsUpdate: "pullRequests.update", pullRequestsComment: "pullRequests.comment", @@ -517,6 +521,23 @@ export const WsPullRequestsDiffFileContentsRpc = Rpc.make(WS_METHODS.pullRequest error: PullRequestRpcError, }); +/** + * Which files the reader has already cleared. Its own call rather than a field on the diff: the + * patch is cached by the minute and this moves on every press of a checkbox, so sharing a read + * would make one of the two wrong. + */ +export const WsPullRequestsFilesViewedRpc = Rpc.make(WS_METHODS.pullRequestsFilesViewed, { + payload: PullRequestRef, + success: PullRequestFilesViewedResult, + error: PullRequestRpcError, +}); + +export const WsPullRequestsSetFilesViewedRpc = Rpc.make(WS_METHODS.pullRequestsSetFilesViewed, { + payload: PullRequestSetFilesViewedInput, + success: Schema.Void, + error: PullRequestRpcError, +}); + export const WsPullRequestsRunActionRpc = Rpc.make(WS_METHODS.pullRequestsRunAction, { payload: PullRequestActionInput, success: Schema.Void, @@ -1012,6 +1033,8 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsActivityRpc, WsPullRequestsThreadCommentsRpc, WsPullRequestsDiffFileContentsRpc, + WsPullRequestsFilesViewedRpc, + WsPullRequestsSetFilesViewedRpc, WsPullRequestsRunActionRpc, WsPullRequestsUpdateRpc, WsPullRequestsCommentRpc, From cf5ac4c80da63b8f73630d80aefc0ecf7aac3430 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:09:48 -0400 Subject: [PATCH 02/78] fix(server): an overpriced write guess no longer pauses reads until the window resets Signed-off-by: Yordis Prieto --- .../sourceControl/githubGraphQlBudget.test.ts | 36 +++++++++++++++++++ .../src/sourceControl/githubGraphQlBudget.ts | 24 ++++++++++--- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts index b85371c810e2..da8377b9aeb8 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts @@ -203,6 +203,42 @@ describe("GitHub GraphQL budget", () => { }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), ); + it.effect("takes the host's own number over a write's guess, however high the guess was", () => + Effect.gen(function* () { + yield* TestClock.setTime(BEFORE_RESET); + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + yield* budget.observe("github.com", rateLimit(4_000)); + // A batch charged far more than it really spent would otherwise hold reads until the reset. + yield* budget.query("github.com", "mutation { f0: markFileAsViewed { id } }", { + estimatedCost: 3_900, + }); + yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); + + yield* budget.observe("github.com", rateLimit(3_990)); + + expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( + "rateLimit", + ); + }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), + ); + + it.effect("still ignores an out-of-order answer once the guess has been settled", () => + Effect.gen(function* () { + yield* TestClock.setTime(BEFORE_RESET); + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + yield* budget.observe("github.com", rateLimit(600)); + yield* budget.query("github.com", "mutation { f0: markFileAsViewed { id } }", { + estimatedCost: 50, + }); + // The host's own number settles the guess, and the answer behind it is stale again. + yield* budget.observe("github.com", rateLimit(513)); + yield* budget.observe("github.com", rateLimit(600)); + + const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); + expect(error).toMatchObject({ _tag: "SourceControlRateLimitPausedError" }); + }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), + ); + it.effect("lets a write through even with nothing left, rather than holding a press back", () => Effect.gen(function* () { yield* TestClock.setTime(BEFORE_RESET); diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.ts b/apps/server/src/sourceControl/githubGraphQlBudget.ts index 8745d691bc84..daa52bfb0d62 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.ts @@ -15,6 +15,12 @@ interface GraphQlBudgetSnapshot { readonly limit: number; readonly remaining: number; readonly resetAtMs: number; + /** + * Points taken off `remaining` for writes the host has not answered for yet. A mutation cannot + * ask what it cost, so this is a guess, and while a guess is standing the host's own number is + * allowed to raise `remaining` again instead of being read as an out-of-order answer. + */ + readonly estimatedSpend: number; } export class GitHubGraphQlBudget extends Context.Service< @@ -66,7 +72,9 @@ function snapshotFrom(raw: string): GraphQlBudgetSnapshot | null { return null; } const resetAtMs = Date.parse(resetAt); - return Number.isFinite(resetAtMs) ? { cost, limit, remaining, resetAtMs } : null; + return Number.isFinite(resetAtMs) + ? { cost, limit, remaining, resetAtMs, estimatedSpend: 0 } + : null; } catch { return null; } @@ -91,7 +99,7 @@ export const make = Effect.gen(function* () { function* (host, document, options) { const now = yield* Clock.currentTimeMillis; // A write spends the same hourly points a read does, and `rateLimit` is a field of Query - // alone — so a mutation cannot report its own cost and is debited from the held snapshot + // alone, so a mutation cannot report its own cost and is debited from the held snapshot // instead. Never paused, only counted: a mutation is somebody pressing something, and // holding it back to protect a read nobody has asked for yet is the wrong trade. The // estimate only has to last until the next read, whose answer replaces the snapshot with @@ -101,10 +109,12 @@ export const make = Effect.gen(function* () { const key = hostKey(host); const snapshot = current.get(key); if (snapshot === undefined || snapshot.resetAtMs <= now) return current; + const spend = Math.max(1, options?.estimatedCost ?? 1); const next = new Map(current); next.set(key, { ...snapshot, - remaining: Math.max(0, snapshot.remaining - Math.max(1, options?.estimatedCost ?? 1)), + remaining: Math.max(0, snapshot.remaining - spend), + estimatedSpend: snapshot.estimatedSpend + spend, }); return next; }); @@ -148,10 +158,16 @@ export const make = Effect.gen(function* () { const previous = current.get(key); // Concurrent reads can finish out of order. Quota only falls within one reset window, and // an answer from an older window must not replace the current one. + // + // Unless a write's guess is standing: that number was never the host's, and an estimate + // pitched too high would otherwise pause every read until the window reset, with the one + // answer that could correct it thrown away for looking stale. if ( previous !== undefined && (snapshot.resetAtMs < previous.resetAtMs || - (snapshot.resetAtMs === previous.resetAtMs && snapshot.remaining >= previous.remaining)) + (snapshot.resetAtMs === previous.resetAtMs && + previous.estimatedSpend === 0 && + snapshot.remaining >= previous.remaining)) ) { return current; } From e62d906b920142210c31218c5d6b011c53ce8cf1 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:09:49 -0400 Subject: [PATCH 03/78] fix(web): a failed press no longer takes back a tick the reader made since Signed-off-by: Yordis Prieto --- .../pullRequestFilesViewed.logic.test.ts | 32 ++++++++ .../pullRequestFilesViewed.logic.ts | 22 +++++- .../pullRequest/usePullRequestFilesViewed.ts | 77 +++++++++++-------- 3 files changed, 100 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts index 90c3d71f9b04..78dd8d1298ee 100644 --- a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts @@ -4,6 +4,7 @@ import { countViewedFiles, isFileViewed, isStaleViewedState, + revertFileViewedOverlay, settleFileViewedOverlay, toFileViewedBatch, toFileViewedStates, @@ -98,3 +99,34 @@ describe("toFileViewedBatch", () => { ]); }); }); + +describe("revertFileViewedOverlay", () => { + const batch = [ + { path: "a.ts", viewed: true }, + { path: "b.ts", viewed: false }, + ]; + + it("puts the checkbox back to the host's answer for everything the request carried", () => { + const overlay = new Map([ + ["a.ts", true], + ["b.ts", false], + ]); + expect(revertFileViewedOverlay(overlay, batch, new Set()).size).toBe(0); + }); + + it("leaves a press the reader made after the request went out", () => { + // The second press is queued behind a request of its own, so the first one failing says + // nothing about it. + const overlay = new Map([ + ["a.ts", false], + ["b.ts", false], + ]); + const reverted = revertFileViewedOverlay(overlay, batch, new Set(["a.ts"])); + expect([...reverted]).toEqual([["a.ts", false]]); + }); + + it("leaves a path the request never carried", () => { + const overlay = new Map([["c.ts", true]]); + expect(revertFileViewedOverlay(overlay, batch, new Set())).toBe(overlay); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts index 2bc011297a53..04c03ba424fa 100644 --- a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts @@ -17,7 +17,7 @@ export function toFileViewedStates( * Whether a file counts as seen. * * `dismissed` is the host saying it has been pushed to since the reader cleared it, which reads - * as unseen — the point of the tick is that the code behind it has been looked at, and it is not + * as unseen: the point of the tick is that the code behind it has been looked at, and it is not * the same code any more. */ export function isViewedState(state: PullRequestFileViewedState | undefined): boolean { @@ -74,6 +74,26 @@ export function settleFileViewedOverlay( return next.size === overlay.size ? overlay : next; } +/** + * The overlay with a failed request's presses taken back. + * + * Only the presses that request carried, and only where the checkbox still shows them: a path + * the reader has pressed again since is waiting on a request of its own, and putting that box + * back to the host's answer would take a press out from under the reader's hand. + */ +export function revertFileViewedOverlay( + overlay: FileViewedOverlay, + batch: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>, + superseded: ReadonlySet, +): FileViewedOverlay { + const next = new Map(overlay); + for (const { path, viewed } of batch) { + if (superseded.has(path)) continue; + if (next.get(path) === viewed) next.delete(path); + } + return next.size === overlay.size ? overlay : next; +} + /** The presses in an overlay as the batch the host is told about. */ export function toFileViewedBatch( overlay: FileViewedOverlay, diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index 32d6934a57ec..b84028ee7352 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -10,6 +10,7 @@ import { countViewedFiles, isFileViewed, isStaleViewedState, + revertFileViewedOverlay, settleFileViewedOverlay, toFileViewedBatch, toFileViewedStates, @@ -24,7 +25,6 @@ import { const FLUSH_DELAY_MS = 400; const NO_OVERLAY: FileViewedOverlay = new Map(); -const NO_PATHS: ReadonlySet = new Set(); export interface PullRequestFilesViewedView { /** Whether the host tracks this at all, which is what hides the whole control. */ @@ -35,6 +35,8 @@ export interface PullRequestFilesViewedView { readonly setViewed: (path: string, viewed: boolean) => void; /** How many of the files on screen are ticked off. */ readonly viewedCount: number; + /** The host had more files than the read covered, so the count above may be short. */ + readonly truncated: boolean; } /** @@ -57,30 +59,28 @@ export function usePullRequestFilesViewed(options: { ); const refresh = query.refresh; const states = useMemo(() => toFileViewedStates(query.data), [query.data]); + const truncated = query.data?.truncated === true; const [overlay, setOverlay] = useState(NO_OVERLAY); const setFilesViewed = useAtomCommand(pullRequestEnvironment.setFilesViewed); // Presses waiting for the next flush, and the ones a request is already carrying. Both are // refs rather than state: nothing on screen reads them, and the flush must see the latest. const queued = useRef>(new Map()); - const inFlight = useRef>(NO_PATHS); + const inFlight = useRef>(new Map()); const flushTimer = useRef | null>(null); - const referenceKey = `${reference.projectId} ${reference.repository} ${reference.number}`; - // Everything held here is about one change request, so switching away drops it rather than - // letting a press meant for one land on another. - useEffect(() => { - queued.current = new Map(); - inFlight.current = NO_PATHS; - setOverlay(NO_OVERLAY); - }, [referenceKey]); + // Everything held here belongs to one change request on one environment. The environment is + // part of that: two of them can hand out the same project id, and a press made against one + // must never be answered for by the other. + const scopeKey = `${environmentId} ${reference.projectId} ${reference.repository} ${reference.number}`; + const scope = useRef(scopeKey); useEffect(() => { setOverlay((current) => settleFileViewedOverlay( current, states, - new Set([...queued.current.keys(), ...inFlight.current]), + new Set([...queued.current.keys(), ...inFlight.current.keys()]), ), ); }, [states]); @@ -90,17 +90,22 @@ export function usePullRequestFilesViewed(options: { const batch = toFileViewedBatch(queued.current); if (batch.length === 0) return; queued.current = new Map(); - const sent = new Set(batch.map((file) => file.path)); - inFlight.current = sent; + const sentFrom = scope.current; + for (const file of batch) inFlight.current.set(file.path, file.viewed); void setFilesViewed({ environmentId, input: { ...reference, files: batch } }).then((result) => { - inFlight.current = NO_PATHS; + // Only what this request carried, and only where a later press has not taken the path over. + for (const file of batch) { + if (inFlight.current.get(file.path) === file.viewed) inFlight.current.delete(file.path); + } + // The reader has moved to another change request, or another environment, and what is on + // screen now has nothing to do with this answer. + if (scope.current !== sentFrom) return; if (result._tag === "Failure") { - // The host never heard these, so the ticks go back to whatever it last said. - setOverlay((current) => { - const next = new Map(current); - for (const path of sent) next.delete(path); - return next; - }); + // The host never heard these, so the ticks go back to whatever it last said, except on + // a path pressed again since, where the newer press is still waiting on its own request. + setOverlay((current) => + revertFileViewedOverlay(current, batch, new Set(queued.current.keys())), + ); toastManager.add({ type: "error", title: "Could not update viewed files" }); return; } @@ -113,15 +118,22 @@ export function usePullRequestFilesViewed(options: { const flushRef = useRef(flush); flushRef.current = flush; - // A tab closed mid-gather still records what was pressed. - useEffect( - () => () => { - if (flushTimer.current === null) return; - clearTimeout(flushTimer.current); - flushRef.current(); - }, - [], - ); + // Leaving a change request, the environment it lives on, or the page itself records what was + // pressed and then drops the rest. The flush kept here is the one bound to the scope being + // left, which is what sends those last presses where they were meant to go. + useEffect(() => { + const flushScope = flushRef.current; + scope.current = scopeKey; + return () => { + if (flushTimer.current !== null) { + clearTimeout(flushTimer.current); + flushScope(); + } + queued.current = new Map(); + inFlight.current = new Map(); + setOverlay(NO_OVERLAY); + }; + }, [scopeKey]); const setViewed = useCallback((path: string, viewed: boolean) => { setOverlay((current) => new Map(current).set(path, viewed)); @@ -143,5 +155,10 @@ export function usePullRequestFilesViewed(options: { [overlay, paths, states], ); - return { enabled, isViewed, isStale, setViewed, viewedCount }; + // One identity per change of what it says: the viewer keys every file it draws off this, and a + // fresh object each render would redraw the whole diff. + return useMemo( + () => ({ enabled, isViewed, isStale, setViewed, viewedCount, truncated }), + [enabled, isStale, isViewed, setViewed, truncated, viewedCount], + ); } From 19a679c710c9ee4638b00047eaef177d6897a12a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:09:49 -0400 Subject: [PATCH 04/78] fix(web): a viewed tick redraws its file, and a partial count says that it is partial Signed-off-by: Yordis Prieto --- .../pullRequest/PullRequestCodeTab.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index fa9e5ed97026..772333d94350 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -485,6 +485,12 @@ export function PullRequestCodeTab({ } const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); + // The header carries the reader's own tick, and the viewer redraws a file only when its + // version moves. Ticking a file that is already folded changes no fold, so without this + // the box on screen would keep saying the opposite of what the count says. + const viewedMark = filesViewed.enabled + ? `${filesViewed.isViewed(path) ? "v" : ""}${filesViewed.isStale(path) ? "s" : ""}` + : ""; const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({ side: toViewerSide(group.side), @@ -500,7 +506,7 @@ export function PullRequestCodeTab({ // The viewer re-renders an item only when its version changes, so everything the // annotations show has to be part of it. version: fnv1a32( - `${collapsed ? "1" : "0"}:${annotations + `${collapsed ? "1" : "0"}:${viewedMark}:${annotations .map( ({ side, lineNumber, metadata }) => `${side}:${lineNumber}:${metadata.draft ? "d" : ""}:${metadata.pending @@ -534,6 +540,7 @@ export function PullRequestCodeTab({ detail.reviewThreads, draft, files, + filesViewed, foldOverride, pendingComments, placedThreadIds, @@ -774,7 +781,6 @@ export function PullRequestCodeTab({ > setFileViewed(item.id, path, next === true)} /> {stale ? ( @@ -1118,8 +1124,22 @@ export function PullRequestCodeTab({ {nextCursor === null ? "" : "+"} {filesViewed.enabled && files.length > 0 ? ( - + {filesViewed.viewedCount} / {files.length} viewed + {filesViewed.truncated ? ( + + }> + + + + This change has more files than the host will report ticks for in one read, so + the count is short and some boxes below start empty. + + + ) : null} ) : null} {withheldContent ? ( From 447fd191c007ec85345588b713522107ee5954db Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:09:50 -0400 Subject: [PATCH 05/78] style: plainer punctuation in the viewed files comments Signed-off-by: Yordis Prieto --- apps/server/src/pullRequest/gitHubPullRequestJson.ts | 12 ++++++------ .../components/pullRequest/pullRequestDiff.logic.ts | 2 +- packages/contracts/src/pullRequest.ts | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 773b3aa6700b..c77514b5f6d6 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -2244,10 +2244,10 @@ export function decodePullRequestFilesJson( /** * Which files of a pull request the signed-in account has cleared. * - * GraphQL only — the REST files endpoint the patch is read from carries no viewed state at all, - * so this is a second read rather than a wider version of the first. One page of a hundred files - * costs a single point of the hourly budget, which is why it can ride the diff's own refresh - * without being noticed. + * GraphQL only, since the REST files endpoint the patch is read from carries no viewed state at + * all, so this is a second read rather than a wider version of the first. One page of a hundred + * files costs a single point of the hourly budget, which is why it can ride the diff's own + * refresh without being noticed. */ export const PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $name) { @@ -2335,8 +2335,8 @@ export function decodePullRequestFilesViewedJson( * One document that clears and restores as many files as the reader ticked, rather than one * request each. * - * GitHub has no bulk form of either mutation — `markFileAsViewed` and `unmarkFileAsViewed` take a - * single path — so the batching is done with aliases. Top-level mutation fields run in the order + * GitHub has no bulk form of either mutation, and `markFileAsViewed` and `unmarkFileAsViewed` + * take a single path, so the batching is done with aliases. Top-level mutation fields run in the order * they are written, so the last word about a path is the one that sticks, and the whole burst * costs one HTTP round trip and one subprocess instead of one of each per press. * diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts index 8a6061c4e5c6..75b680be1790 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts @@ -47,7 +47,7 @@ export function isFileDiffCollapsed( * The reader's fold choices after a file was ticked off, or put back. * * Clearing a file puts it away and un-clearing brings it back, so the tick moves the fold as if - * the reader had pressed the chevron themselves — which keeps folding a difference from what the + * the reader had pressed the chevron themselves, which keeps folding a difference from what the * toolbar last asked, and so keeps "collapse all" from ticking anything off. */ export function toggleFileDiffFoldForViewed( diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 86a8927d4461..598c7caf18ad 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -390,7 +390,7 @@ export const PullRequestCapabilities = Schema.Struct({ * every server before this field was. * * True on GitHub alone so far. The others expose no equivalent, and a checkbox whose mark is - * forgotten the moment the tab closes is worse than no checkbox — it looks like the one beside + * forgotten the moment the tab closes is worse than no checkbox: it looks like the one beside * it and keeps none of its promises. */ viewedFiles: Schema.optional(Schema.Boolean), @@ -814,7 +814,7 @@ export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileConten * Where one file of a change request stands with the person reading it. * * `dismissed` is the state that earns this its own read: the file was cleared, and has since been - * pushed to. It is not `viewed` — the reader has not seen what is there now — and it is not + * pushed to. It is not `viewed`, since the reader has not seen what is there now, and it is not * `unviewed` either, because saying so would lose the one thing worth telling them, which is that * this file and not the other forty is the one that moved. */ From d085d082b29b36578cd8d86b551041fff3a929da Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:22:06 -0400 Subject: [PATCH 06/78] fix(web): pressing the word beside the box no longer folds the file the wrong way Signed-off-by: Yordis Prieto --- .../sourceControl/githubGraphQlBudget.test.ts | 63 ------------------- .../src/sourceControl/githubGraphQlBudget.ts | 50 ++------------- .../pullRequest/PullRequestCodeTab.tsx | 9 ++- 3 files changed, 12 insertions(+), 110 deletions(-) diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts index da8377b9aeb8..a166bf0dbbaf 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts @@ -186,67 +186,4 @@ describe("GitHub GraphQL budget", () => { expect(yield* budget.query("github.com", mutation)).toBe(mutation); }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), ); - - it.effect("charges a write for the batch it carries, since it cannot report its own cost", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - // Twenty points above the reserve, which is exactly what the mutation below spends. - yield* budget.observe("github.com", rateLimit(520)); - - yield* budget.query("github.com", "mutation { f0: markFileAsViewed { id } }", { - estimatedCost: 20, - }); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ _tag: "SourceControlRateLimitPausedError" }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("takes the host's own number over a write's guess, however high the guess was", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(4_000)); - // A batch charged far more than it really spent would otherwise hold reads until the reset. - yield* budget.query("github.com", "mutation { f0: markFileAsViewed { id } }", { - estimatedCost: 3_900, - }); - yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - - yield* budget.observe("github.com", rateLimit(3_990)); - - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("still ignores an out-of-order answer once the guess has been settled", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(600)); - yield* budget.query("github.com", "mutation { f0: markFileAsViewed { id } }", { - estimatedCost: 50, - }); - // The host's own number settles the guess, and the answer behind it is stale again. - yield* budget.observe("github.com", rateLimit(513)); - yield* budget.observe("github.com", rateLimit(600)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ _tag: "SourceControlRateLimitPausedError" }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("lets a write through even with nothing left, rather than holding a press back", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(0)); - - const mutation = "mutation { f0: markFileAsViewed { id } }"; - expect(yield* budget.query("github.com", mutation, { estimatedCost: 40 })).toBe(mutation); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); }); diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.ts b/apps/server/src/sourceControl/githubGraphQlBudget.ts index daa52bfb0d62..9c43de8e0586 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.ts @@ -15,12 +15,6 @@ interface GraphQlBudgetSnapshot { readonly limit: number; readonly remaining: number; readonly resetAtMs: number; - /** - * Points taken off `remaining` for writes the host has not answered for yet. A mutation cannot - * ask what it cost, so this is a guess, and while a guess is standing the host's own number is - * allowed to raise `remaining` again instead of being read as an out-of-order answer. - */ - readonly estimatedSpend: number; } export class GitHubGraphQlBudget extends Context.Service< @@ -29,14 +23,7 @@ export class GitHubGraphQlBudget extends Context.Service< readonly query: ( host: string, document: string, - options?: { - readonly allowReserve?: boolean | undefined; - /** - * What a write is expected to spend, for the debit above. Ignored for a read, which - * reports its own cost. Defaults to one point, which is a mutation's floor. - */ - readonly estimatedCost?: number | undefined; - }, + options?: { readonly allowReserve: boolean }, ) => Effect.Effect; readonly observe: (host: string, raw: string) => Effect.Effect; } @@ -72,9 +59,7 @@ function snapshotFrom(raw: string): GraphQlBudgetSnapshot | null { return null; } const resetAtMs = Date.parse(resetAt); - return Number.isFinite(resetAtMs) - ? { cost, limit, remaining, resetAtMs, estimatedSpend: 0 } - : null; + return Number.isFinite(resetAtMs) ? { cost, limit, remaining, resetAtMs } : null; } catch { return null; } @@ -97,29 +82,8 @@ export const make = Effect.gen(function* () { const query: GitHubGraphQlBudget["Service"]["query"] = Effect.fn("GitHubGraphQlBudget.query")( function* (host, document, options) { + if (!isReadOperation(document)) return document; const now = yield* Clock.currentTimeMillis; - // A write spends the same hourly points a read does, and `rateLimit` is a field of Query - // alone, so a mutation cannot report its own cost and is debited from the held snapshot - // instead. Never paused, only counted: a mutation is somebody pressing something, and - // holding it back to protect a read nobody has asked for yet is the wrong trade. The - // estimate only has to last until the next read, whose answer replaces the snapshot with - // the host's own number. - if (!isReadOperation(document)) { - yield* Ref.update(snapshots, (current) => { - const key = hostKey(host); - const snapshot = current.get(key); - if (snapshot === undefined || snapshot.resetAtMs <= now) return current; - const spend = Math.max(1, options?.estimatedCost ?? 1); - const next = new Map(current); - next.set(key, { - ...snapshot, - remaining: Math.max(0, snapshot.remaining - spend), - estimatedSpend: snapshot.estimatedSpend + spend, - }); - return next; - }); - return document; - } const retryAt = yield* Ref.modify(snapshots, (current) => { const key = hostKey(host); const snapshot = current.get(key); @@ -158,16 +122,10 @@ export const make = Effect.gen(function* () { const previous = current.get(key); // Concurrent reads can finish out of order. Quota only falls within one reset window, and // an answer from an older window must not replace the current one. - // - // Unless a write's guess is standing: that number was never the host's, and an estimate - // pitched too high would otherwise pause every read until the window reset, with the one - // answer that could correct it thrown away for looking stale. if ( previous !== undefined && (snapshot.resetAtMs < previous.resetAtMs || - (snapshot.resetAtMs === previous.resetAtMs && - previous.estimatedSpend === 0 && - snapshot.remaining >= previous.remaining)) + (snapshot.resetAtMs === previous.resetAtMs && snapshot.remaining >= previous.remaining)) ) { return current; } diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 772333d94350..15f61bfdd401 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -774,8 +774,11 @@ export function PullRequestCodeTab({ return ( {stat} - {/* The header itself folds the file, so the tick has to keep its press to itself. */} + {/* The header itself folds the file, so the tick has to keep its press to itself. The + attribute is what the header's capture listener looks for: pressing the word next to + the box is pressing the box, and the fold that follows is the tick's to make. */} (input: { @@ -1861,7 +1850,6 @@ export const make = Effect.gen(function* () { host: input.host, query: mutation.query, variables: { pullRequestId, ...mutation.variables }, - estimatedCost: input.files.length, }), ), ); From dad689d0a63a9db6b54bc5e407227430266e0916 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:22:10 -0400 Subject: [PATCH 08/78] fix(web): a failed request no longer answers for a press a later one carries Signed-off-by: Yordis Prieto --- .../pullRequestFilesViewed.logic.test.ts | 20 +++++++---- .../pullRequestFilesViewed.logic.ts | 12 ++++--- .../pullRequest/usePullRequestFilesViewed.ts | 35 +++++++++++-------- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts index 78dd8d1298ee..88d3f5708c09 100644 --- a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts @@ -105,28 +105,36 @@ describe("revertFileViewedOverlay", () => { { path: "a.ts", viewed: true }, { path: "b.ts", viewed: false }, ]; + const both = new Set(["a.ts", "b.ts"]); - it("puts the checkbox back to the host's answer for everything the request carried", () => { + it("puts the checkbox back to the host's answer for everything the request answers for", () => { const overlay = new Map([ ["a.ts", true], ["b.ts", false], ]); - expect(revertFileViewedOverlay(overlay, batch, new Set()).size).toBe(0); + expect(revertFileViewedOverlay(overlay, batch, both).size).toBe(0); }); it("leaves a press the reader made after the request went out", () => { - // The second press is queued behind a request of its own, so the first one failing says - // nothing about it. + // The second press is waiting on a flush of its own, so the first one failing says nothing + // about it. const overlay = new Map([ ["a.ts", false], ["b.ts", false], ]); - const reverted = revertFileViewedOverlay(overlay, batch, new Set(["a.ts"])); + const reverted = revertFileViewedOverlay(overlay, batch, new Set(["b.ts"])); expect([...reverted]).toEqual([["a.ts", false]]); }); + it("leaves a path a later request took over, even pressed the same way", () => { + // Both requests carry `a.ts` as viewed, so the value cannot tell them apart. The later one + // owns the path now and is the one that answers for it. + const overlay = new Map([["a.ts", true]]); + expect(revertFileViewedOverlay(overlay, batch, new Set(["b.ts"]))).toBe(overlay); + }); + it("leaves a path the request never carried", () => { const overlay = new Map([["c.ts", true]]); - expect(revertFileViewedOverlay(overlay, batch, new Set())).toBe(overlay); + expect(revertFileViewedOverlay(overlay, batch, both)).toBe(overlay); }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts index 04c03ba424fa..14d3d462ec7b 100644 --- a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts @@ -77,18 +77,20 @@ export function settleFileViewedOverlay( /** * The overlay with a failed request's presses taken back. * - * Only the presses that request carried, and only where the checkbox still shows them: a path - * the reader has pressed again since is waiting on a request of its own, and putting that box - * back to the host's answer would take a press out from under the reader's hand. + * `owned` are the paths that request still answers for, which is what keeps a failure from + * reaching past its own presses: a path pressed again since belongs to a later request or to the + * next flush, and putting that box back to the host's answer would take a press out from under + * the reader's hand. Even among those, a press is only taken back where the checkbox still shows + * it. */ export function revertFileViewedOverlay( overlay: FileViewedOverlay, batch: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>, - superseded: ReadonlySet, + owned: ReadonlySet, ): FileViewedOverlay { const next = new Map(overlay); for (const { path, viewed } of batch) { - if (superseded.has(path)) continue; + if (!owned.has(path)) continue; if (next.get(path) === viewed) next.delete(path); } return next.size === overlay.size ? overlay : next; diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index b84028ee7352..fc1ecb671881 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -63,10 +63,14 @@ export function usePullRequestFilesViewed(options: { const [overlay, setOverlay] = useState(NO_OVERLAY); const setFilesViewed = useAtomCommand(pullRequestEnvironment.setFilesViewed); - // Presses waiting for the next flush, and the ones a request is already carrying. Both are - // refs rather than state: nothing on screen reads them, and the flush must see the latest. + // Presses waiting for the next flush, and, for every path a request is already carrying, which + // request that is. Requests overlap and run in the order they were made, so a path pressed + // again while an earlier one is still out belongs to the later request from that moment on, and + // the earlier one stops answering for it. Both are refs rather than state: nothing on screen + // reads them, and the flush must see the latest. const queued = useRef>(new Map()); - const inFlight = useRef>(new Map()); + const sentBy = useRef>(new Map()); + const requests = useRef(0); const flushTimer = useRef | null>(null); // Everything held here belongs to one change request on one environment. The environment is @@ -80,7 +84,7 @@ export function usePullRequestFilesViewed(options: { settleFileViewedOverlay( current, states, - new Set([...queued.current.keys(), ...inFlight.current.keys()]), + new Set([...queued.current.keys(), ...sentBy.current.keys()]), ), ); }, [states]); @@ -91,21 +95,22 @@ export function usePullRequestFilesViewed(options: { if (batch.length === 0) return; queued.current = new Map(); const sentFrom = scope.current; - for (const file of batch) inFlight.current.set(file.path, file.viewed); + const request = ++requests.current; + for (const file of batch) sentBy.current.set(file.path, request); void setFilesViewed({ environmentId, input: { ...reference, files: batch } }).then((result) => { - // Only what this request carried, and only where a later press has not taken the path over. - for (const file of batch) { - if (inFlight.current.get(file.path) === file.viewed) inFlight.current.delete(file.path); - } + const mine = batch + .map((file) => file.path) + .filter((path) => sentBy.current.get(path) === request); + for (const path of mine) sentBy.current.delete(path); // The reader has moved to another change request, or another environment, and what is on // screen now has nothing to do with this answer. if (scope.current !== sentFrom) return; if (result._tag === "Failure") { - // The host never heard these, so the ticks go back to whatever it last said, except on - // a path pressed again since, where the newer press is still waiting on its own request. - setOverlay((current) => - revertFileViewedOverlay(current, batch, new Set(queued.current.keys())), - ); + // The host never heard these, so the ticks go back to whatever it last said. Only the + // paths this request still answers for: one pressed again since is waiting on a request + // of its own, or on the next flush, and that press is the one on screen. + const owned = new Set(mine.filter((path) => !queued.current.has(path))); + setOverlay((current) => revertFileViewedOverlay(current, batch, owned)); toastManager.add({ type: "error", title: "Could not update viewed files" }); return; } @@ -130,7 +135,7 @@ export function usePullRequestFilesViewed(options: { flushScope(); } queued.current = new Map(); - inFlight.current = new Map(); + sentBy.current = new Map(); setOverlay(NO_OVERLAY); }; }, [scopeKey]); From 962864338f56630528864b03a374de807515b77d Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:38:40 -0400 Subject: [PATCH 09/78] fix(web): a viewed tick no longer rebuilds every header on screen A press moved the whole viewed view, and every file header on screen was memoized on it, so one tick cost a rebuild of all of them. The same mark also has to say whether the control is offered at all, or a capability arriving after the first paint leaves the headers without a box. Signed-off-by: Yordis Prieto --- .../pullRequest/PullRequestCodeTab.tsx | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 15f61bfdd401..36326e6e2f78 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -489,7 +489,7 @@ export function PullRequestCodeTab({ // version moves. Ticking a file that is already folded changes no fold, so without this // the box on screen would keep saying the opposite of what the count says. const viewedMark = filesViewed.enabled - ? `${filesViewed.isViewed(path) ? "v" : ""}${filesViewed.isStale(path) ? "s" : ""}` + ? `e${filesViewed.isViewed(path) ? "v" : ""}${filesViewed.isStale(path) ? "s" : ""}` : ""; const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({ @@ -747,6 +747,15 @@ export function PullRequestCodeTab({ [toggleFile], ); + // Read through refs rather than closed over. The viewer memoizes each visible file's header + // portal on the callback below, so a fresh identity on every tick, and on every refresh of the + // host's answer, would rebuild every header on screen. Each item's version carries the same + // marks, which is what redraws the one file whose tick moved. + const filesViewedRef = useRef(filesViewed); + filesViewedRef.current = filesViewed; + const setFileViewedRef = useRef(setFileViewed); + setFileViewedRef.current = setFileViewed; + const renderHeaderMetadata = useCallback( (item: CodeViewItem) => { if (item.type !== "diff") return null; @@ -768,9 +777,10 @@ export function PullRequestCodeTab({ className="font-mono text-[11px]" /> ); - if (!filesViewed.enabled) return stat; - const viewed = filesViewed.isViewed(path); - const stale = filesViewed.isStale(path); + const viewedFiles = filesViewedRef.current; + if (!viewedFiles.enabled) return stat; + const viewed = viewedFiles.isViewed(path); + const stale = viewedFiles.isStale(path); return ( {stat} @@ -784,7 +794,7 @@ export function PullRequestCodeTab({ > setFileViewed(item.id, path, next === true)} + onCheckedChange={(next) => setFileViewedRef.current(item.id, path, next === true)} /> {stale ? ( @@ -802,7 +812,7 @@ export function PullRequestCodeTab({ ); }, - [filesViewed, omittedFileStats, setFileViewed], + [omittedFileStats], ); const diffViewOptions = useMemo( From 6b44e5156469157681c5c0b91b112f5ad12ed3f7 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:38:47 -0400 Subject: [PATCH 10/78] fix(web): the viewed box says what it is for out loud It borrowed its name from the label beside it, and that label turns into "Changed" once the file has been pushed to, leaving a reader who cannot see it with no idea what the box does. Signed-off-by: Yordis Prieto --- apps/web/src/components/pullRequest/PullRequestCodeTab.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 36326e6e2f78..815f00a844fe 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -792,7 +792,10 @@ export function PullRequestCodeTab({ className="flex cursor-pointer select-none items-center gap-1.5 text-[11px] text-muted-foreground" onClick={(event) => event.stopPropagation()} > + {/* Named here rather than by the label, whose text turns into "Changed" once the + file has been pushed to. */} setFileViewedRef.current(item.id, path, next === true)} /> From aa7a828204f2703c189cde5c9c3eba0a2b925ab0 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 27 Aug 2026 20:02:24 -0400 Subject: [PATCH 11/78] fix(web): a refreshed review re-asks for the ticks The button exists for a reader who can see that what they are looking at is behind, so leaving one part of the page on the last read defeats the point of pressing it. A push since that read is exactly when the mark beside a ticked file stops being true. Signed-off-by: Yordis Prieto --- .../pullRequest/PullRequestCodeTab.tsx | 20 +++++++++------- .../pullRequest/usePullRequestFilesViewed.ts | 23 +++++++++++++++++-- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 815f00a844fe..23978183e825 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -320,13 +320,6 @@ export function PullRequestCodeTab({ input: { ...reference, ...(commit === null ? {} : { commit }) }, }), ); - const appliedRefreshToken = useRef(refreshToken); - useEffect(() => { - if (appliedRefreshToken.current === refreshToken) return; - appliedRefreshToken.current = refreshToken; - setSliceState({ key: scopeKey, cursor: null, slices: NO_SLICES }); - refreshFirstDiffPage(); - }, [refreshToken, scopeKey, refreshFirstDiffPage]); const reviewKey = referenceKey; const pendingComments = usePendingReviewComments(reference); const addComment = usePullRequestReviewStore((store) => store.addComment); @@ -409,7 +402,18 @@ export function PullRequestCodeTab({ enabled: detail.capabilities.viewedFiles === true, paths: filePaths, }); - const { setViewed } = filesViewed; + const { setViewed, refresh: refreshFilesViewed } = filesViewed; + // The button goes around the host's cache, so everything the tab reads from it starts over: + // the diff from its first page, and with it the ticks, which a push since the last read can + // have marked as standing against an older version of the file. + const appliedRefreshToken = useRef(refreshToken); + useEffect(() => { + if (appliedRefreshToken.current === refreshToken) return; + appliedRefreshToken.current = refreshToken; + setSliceState({ key: scopeKey, cursor: null, slices: NO_SLICES }); + refreshFirstDiffPage(); + refreshFilesViewed(); + }, [refreshToken, scopeKey, refreshFirstDiffPage, refreshFilesViewed]); const nextCursor = loadedSlices.at(-1)?.nextCursor ?? null; // What a slice withheld: the host declining to inline part of it, or a patch the viewer could // not structure and so dropped. Neither says anything about there being more to fetch. diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index fc1ecb671881..06e7b9dfd2af 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -37,6 +37,11 @@ export interface PullRequestFilesViewedView { readonly viewedCount: number; /** The host had more files than the read covered, so the count above may be short. */ readonly truncated: boolean; + /** + * Re-ask the host. The page's refresh button goes around the host's cache, and the ticks and + * the marks beside them are part of what the reader asked to be shown again. + */ + readonly refresh: () => void; } /** @@ -140,6 +145,12 @@ export function usePullRequestFilesViewed(options: { }; }, [scopeKey]); + // Held through a ref for the same reason `setViewed` is: it goes into the view object below, + // which every file header keys off, so it has to keep one identity for the tab's life. + const refreshRef = useRef(refresh); + refreshRef.current = refresh; + const refreshFromHost = useCallback(() => refreshRef.current(), []); + const setViewed = useCallback((path: string, viewed: boolean) => { setOverlay((current) => new Map(current).set(path, viewed)); queued.current.set(path, viewed); @@ -163,7 +174,15 @@ export function usePullRequestFilesViewed(options: { // One identity per change of what it says: the viewer keys every file it draws off this, and a // fresh object each render would redraw the whole diff. return useMemo( - () => ({ enabled, isViewed, isStale, setViewed, viewedCount, truncated }), - [enabled, isStale, isViewed, setViewed, truncated, viewedCount], + () => ({ + enabled, + isViewed, + isStale, + setViewed, + viewedCount, + truncated, + refresh: refreshFromHost, + }), + [enabled, isStale, isViewed, refreshFromHost, setViewed, truncated, viewedCount], ); } From 5f080475793566c93ba87312092e10ad7c996c5e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 27 Aug 2026 20:21:29 -0400 Subject: [PATCH 12/78] fix(web): a superseded write no longer reports a failure An error the reader cannot act on, about a press they have already replaced, reads as their current tick having been lost when it has not. Signed-off-by: Yordis Prieto --- .../components/pullRequest/usePullRequestFilesViewed.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index 06e7b9dfd2af..f8b727e9b66f 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -116,7 +116,12 @@ export function usePullRequestFilesViewed(options: { // of its own, or on the next flush, and that press is the one on screen. const owned = new Set(mine.filter((path) => !queued.current.has(path))); setOverlay((current) => revertFileViewedOverlay(current, batch, owned)); - toastManager.add({ type: "error", title: "Could not update viewed files" }); + // Nothing here was still this request's to answer for, so nothing on screen went back. + // A later press carries every one of these paths now, and it is the one that gets to say + // whether the reader's tick reached the host. + if (owned.size > 0) { + toastManager.add({ type: "error", title: "Could not update viewed files" }); + } return; } refresh(); From 3c279bf305afc0a5cb83791d866c2a0ebb0314ba Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 27 Aug 2026 20:41:43 -0400 Subject: [PATCH 13/78] fix(web): a dropped connection no longer reports a rejected write Signed-off-by: Yordis Prieto --- .../pullRequest/usePullRequestFilesViewed.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index f8b727e9b66f..573b823b1170 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -1,3 +1,4 @@ +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -66,7 +67,9 @@ export function usePullRequestFilesViewed(options: { const states = useMemo(() => toFileViewedStates(query.data), [query.data]); const truncated = query.data?.truncated === true; const [overlay, setOverlay] = useState(NO_OVERLAY); - const setFilesViewed = useAtomCommand(pullRequestEnvironment.setFilesViewed); + const setFilesViewed = useAtomCommand(pullRequestEnvironment.setFilesViewed, { + reportFailure: false, + }); // Presses waiting for the next flush, and, for every path a request is already carrying, which // request that is. Requests overlap and run in the order they were made, so a path pressed @@ -116,10 +119,11 @@ export function usePullRequestFilesViewed(options: { // of its own, or on the next flush, and that press is the one on screen. const owned = new Set(mine.filter((path) => !queued.current.has(path))); setOverlay((current) => revertFileViewedOverlay(current, batch, owned)); - // Nothing here was still this request's to answer for, so nothing on screen went back. - // A later press carries every one of these paths now, and it is the one that gets to say - // whether the reader's tick reached the host. - if (owned.size > 0) { + // Two silences here. Nothing was still this request's to answer for, so nothing on + // screen went back and a later press is the one that gets to speak for these paths. Or + // the connection went away mid-flight, which the reader is already being told about and + // which the host never refused. + if (owned.size > 0 && !isAtomCommandInterrupted(result)) { toastManager.add({ type: "error", title: "Could not update viewed files" }); } return; From f5c7f62c7c7364036d12e093a4cf593cfd72180b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 09:26:51 -0400 Subject: [PATCH 14/78] feat(server): GitLab reviewers can keep their place in a long merge request GitLab keeps viewed files in one browser's local storage, so there was nothing to read or write there. The marks are this environment's instead, and the surface says whose they are rather than implying gitlab.com will show them. Signed-off-by: Yordis Prieto --- apps/server/src/persistence/Errors.ts | 1 + apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/044_PullRequestFilesViewed.ts | 24 +++ .../src/persistence/PullRequestFilesViewed.ts | 156 ++++++++++++++ .../pullRequest/GitHubPullRequestProvider.ts | 2 +- .../pullRequest/GitLabPullRequestCli.test.ts | 125 ++++++++++++ .../src/pullRequest/GitLabPullRequestCli.ts | 79 +++++++ .../pullRequest/GitLabPullRequestProvider.ts | 14 ++ .../src/pullRequest/PullRequestProvider.ts | 36 +++- .../pullRequest/PullRequestService.test.ts | 192 +++++++++++++++++- .../src/pullRequest/PullRequestService.ts | 185 ++++++++++++++--- .../gitLabMergeRequestJson.test.ts | 67 ++++++ .../src/pullRequest/gitLabMergeRequestJson.ts | 74 +++++++ apps/server/src/server.test.ts | 6 +- apps/server/src/server.ts | 3 + .../pullRequest/PullRequestCodeTab.tsx | 23 ++- .../pullRequest/usePullRequestFilesViewed.ts | 14 +- docs/user/source-control.md | 8 +- packages/contracts/src/pullRequest.ts | 29 ++- 19 files changed, 985 insertions(+), 55 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/044_PullRequestFilesViewed.ts create mode 100644 apps/server/src/persistence/PullRequestFilesViewed.ts diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 03edaec77d63..1772d0a23a6c 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -134,5 +134,6 @@ export type OrchestrationCommandReceiptRepositoryError = export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError; +export type PullRequestFilesViewedRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 8abbe87fce3e..1cf4ae548dd4 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -56,6 +56,7 @@ import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; +import Migration0044 from "./Migrations/044_PullRequestFilesViewed.ts"; /** * Migration loader with all migrations defined inline. @@ -111,6 +112,7 @@ export const migrationEntries = [ [41, "AuthSessionClientConnection", Migration0041], [42, "ProjectionThreadLinkedPullRequest", Migration0042], [43, "ProjectionThreadsUnsettledAt", Migration0043], + [44, "PullRequestFilesViewed", Migration0044], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/044_PullRequestFilesViewed.ts b/apps/server/src/persistence/Migrations/044_PullRequestFilesViewed.ts new file mode 100644 index 000000000000..53183ccebbd7 --- /dev/null +++ b/apps/server/src/persistence/Migrations/044_PullRequestFilesViewed.ts @@ -0,0 +1,24 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // One row per file a reader has cleared on a host that keeps no record of its own. `revision` + // is what the file was when it was cleared, so a push that changes it is reported as changed + // rather than silently left ticked. Unticking deletes the row: absent is the resting state, and + // a table of "not viewed" rows would grow with every diff anybody scrolled past. + yield* sql` + CREATE TABLE IF NOT EXISTS pull_request_files_viewed ( + provider TEXT NOT NULL, + host TEXT NOT NULL, + repository TEXT NOT NULL, + number INTEGER NOT NULL, + viewer TEXT NOT NULL, + path TEXT NOT NULL, + revision TEXT NOT NULL, + viewed_at TEXT NOT NULL, + PRIMARY KEY (provider, host, repository, number, viewer, path) + ) WITHOUT ROWID + `; +}); diff --git a/apps/server/src/persistence/PullRequestFilesViewed.ts b/apps/server/src/persistence/PullRequestFilesViewed.ts new file mode 100644 index 000000000000..fada7d5271be --- /dev/null +++ b/apps/server/src/persistence/PullRequestFilesViewed.ts @@ -0,0 +1,156 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { SourceControlProviderKind } from "@t3tools/contracts"; + +import { + PersistenceDecodeError, + PersistenceSqlError, + type PullRequestFilesViewedRepositoryError, +} from "./Errors.ts"; + +/** + * Which change request, on which host, for which reader. + * + * The host is part of it because a repository path is not unique across installs: the same + * `group/project` exists on gitlab.com and on a self-managed instance, and a mark made against one + * must not turn up on the other. The reader is part of it for the same reason the host's own + * record is per-account: signing in as somebody else must not inherit their ticks. A host that + * will not say who the reader is leaves it empty, which is one reader rather than none. + */ +export const PullRequestFilesViewedScope = Schema.Struct({ + provider: SourceControlProviderKind, + host: Schema.String, + repository: Schema.String, + number: Schema.Int, + viewer: Schema.String, +}); +export type PullRequestFilesViewedScope = typeof PullRequestFilesViewedScope.Type; + +/** A file this reader cleared, and what it was when they cleared it. */ +export const PullRequestFileViewedMark = Schema.Struct({ + path: Schema.String, + /** + * The host's own name for that version of the file, opaque here. Empty where the host had none + * to give, which is its own answer rather than a missing one: a file with no version at the head + * is one the change request deletes, and it stays deleted. + */ + revision: Schema.String, +}); +export type PullRequestFileViewedMark = typeof PullRequestFileViewedMark.Type; + +export interface SetPullRequestFilesViewedInput extends PullRequestFilesViewedScope { + readonly files: ReadonlyArray; + /** When the presses landed, as an ISO instant. */ + readonly viewedAt: string; +} + +/** + * The marks this environment keeps for hosts that keep none of their own. + * + * Only cleared files are rows. Unticking deletes rather than writing a "not viewed" row, so the + * table holds what a reader has done and not what they have merely scrolled past. + */ +export class PullRequestFilesViewedRepository extends Context.Service< + PullRequestFilesViewedRepository, + { + readonly list: ( + input: PullRequestFilesViewedScope, + ) => Effect.Effect< + ReadonlyArray, + PullRequestFilesViewedRepositoryError + >; + readonly set: ( + input: SetPullRequestFilesViewedInput, + ) => Effect.Effect; + } +>()("t3/persistence/PullRequestFilesViewed/PullRequestFilesViewedRepository") {} + +function toSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): PullRequestFilesViewedRepositoryError => + Schema.isSchemaError(cause) + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) + : new PersistenceSqlError({ operation: sqlOperation, cause }); +} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const listRows = SqlSchema.findAll({ + Request: PullRequestFilesViewedScope, + Result: PullRequestFileViewedMark, + execute: ({ provider, host, repository, number, viewer }) => + sql` + SELECT + path AS "path", + revision AS "revision" + FROM pull_request_files_viewed + WHERE provider = ${provider} + AND host = ${host} + AND repository = ${repository} + AND number = ${number} + AND viewer = ${viewer} + `, + }); + + return PullRequestFilesViewedRepository.of({ + list: (input) => + listRows(input).pipe( + Effect.mapError(toSqlOrDecodeError("listPullRequestFilesViewed", "PullRequestFileViewed")), + ), + + // One statement per file rather than one for the batch: the batch is what a reader ticked in + // the last few hundred milliseconds, so it is a handful of rows on a local database, and a + // mixed batch of clears and un-clears has no single statement anyway. + set: (input) => + Effect.forEach( + input.files, + (file) => + file.viewed + ? sql` + INSERT INTO pull_request_files_viewed ( + provider, + host, + repository, + number, + viewer, + path, + revision, + viewed_at + ) + VALUES ( + ${input.provider}, + ${input.host}, + ${input.repository}, + ${input.number}, + ${input.viewer}, + ${file.path}, + ${file.revision}, + ${input.viewedAt} + ) + ON CONFLICT (provider, host, repository, number, viewer, path) + DO UPDATE SET revision = excluded.revision, viewed_at = excluded.viewed_at + ` + : sql` + DELETE FROM pull_request_files_viewed + WHERE provider = ${input.provider} + AND host = ${input.host} + AND repository = ${input.repository} + AND number = ${input.number} + AND viewer = ${input.viewer} + AND path = ${file.path} + `, + { discard: true }, + ).pipe( + Effect.mapError( + (cause) => new PersistenceSqlError({ operation: "setPullRequestFilesViewed", cause }), + ), + ), + }); +}); + +export const layer = Layer.effect(PullRequestFilesViewedRepository, make); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index ae057251fca9..932beca8d33c 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -33,7 +33,7 @@ const CAPABILITIES: PullRequestCapabilities = { updateMethods: ["merge", "rebase"], search: true, reactions: true, - viewedFiles: true, + viewedFiles: "host", review: { inlineComment: true, reply: true, diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts index 014d91a02740..64c1e6af5aa3 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -1402,4 +1402,129 @@ layer("GitLabPullRequestCli.layer", (it) => { expect(callAt(0).stdin).toBe('{"body":"true"}'); }), ); + it.effect("reads blob ids for the marked paths at the merge request's head", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + iid: 7, + title: "t", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + project: { repository: { blobs: { nodes: [{ path: "src/a.ts", oid: "aaa" }] } } }, + }, + }), + ), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const revisions = yield* cli.getFileRevisions({ + cwd: "/w", + repository: "acme/web", + number: 7, + paths: ["src/a.ts", "src/gone.ts"], + }); + + // A path the head does not have is absent rather than empty, which is the answer for a + // file the merge request deletes. + expect([...revisions]).toEqual([["src/a.ts", "aaa"]]); + // The head the reader is looking at, not whatever the source branch has moved on to. + // @effect-diagnostics-next-line preferSchemaOverJson:off + const body: unknown = JSON.parse(callAt(1).stdin ?? "{}"); + expect(body).toMatchObject({ + variables: { fullPath: "acme/web", ref: "head", paths: ["src/a.ts", "src/gone.ts"] }, + }); + }), + ); + + it.effect("asks GitLab nothing when no file is marked", () => + Effect.gen(function* () { + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const revisions = yield* cli.getFileRevisions({ + cwd: "/w", + repository: "acme/web", + number: 7, + paths: [], + }); + + expect([...revisions]).toEqual([]); + expect(mockedExecute).not.toHaveBeenCalled(); + }), + ); + + it.effect("splits the paths across requests, because GitLab charges the query by how many", () => + Effect.gen(function* () { + const paths = Array.from({ length: 150 }, (_, index) => `src/${index}.ts`); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + iid: 7, + title: "t", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, + }), + ), + ), + ); + mockedExecute.mockImplementation((request) => { + // @effect-diagnostics-next-line preferSchemaOverJson:off + const body = JSON.parse(request.stdin ?? "{}") as { + readonly variables: { readonly paths: ReadonlyArray }; + }; + return Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + project: { + repository: { + blobs: { + nodes: body.variables.paths.map((path) => ({ path, oid: `oid-${path}` })), + }, + }, + }, + }, + }), + ), + ); + }); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const revisions = yield* cli.getFileRevisions({ + cwd: "/w", + repository: "acme/web", + number: 7, + paths, + }); + + assert.strictEqual(revisions.size, 150); + assert.strictEqual(revisions.get("src/149.ts"), "oid-src/149.ts"); + // The diff refs, then two batches: a hundred paths and the fifty left over. + assert.strictEqual(mockedExecute.mock.calls.length, 3); + }), + ); }); diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index 9f968dddbbc8..17da291425fd 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -35,8 +35,10 @@ import { decodeOwnAwardIdJson, decodeProjectMergeCapabilitiesJson, decodeProjectUsersJson, + decodeRepositoryBlobsJson, decodeViewerJson, gitLabAwardName, + REPOSITORY_BLOBS_GRAPHQL_QUERY, type GitLabDiffRefs, type GitLabMergeRequestDetail, type GitLabMergeRequestListItem, @@ -283,6 +285,20 @@ export class GitLabPullRequestCli extends Context.Service< readonly repository: string; }) => Effect.Effect; + /** + * What the merge request's head has of each of these paths, as blob ids. + * + * The head sha comes from the merge request's own diff refs, so the answer is the version a + * reader is looking at rather than whatever the source branch has moved on to. A path the + * head does not have is left out. + */ + readonly getFileRevisions: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly paths: ReadonlyArray; + }) => Effect.Effect, GitLabPullRequestCliError>; + /** * Who this merge request may be sent to, and who it has already been sent to. Two reads at * once, because GitLab keeps the people with access on the project and the reviewers on the @@ -1026,6 +1042,67 @@ export const make = Effect.gen(function* () { }), ); + /** + * GitLab charges the blobs query by how many paths it is handed, and its connection hands back + * one page. A hundred at a time keeps each request inside both. + */ + const BLOB_PATHS_PER_REQUEST = 100; + + const blobsAt = (input: { + readonly cwd: string; + readonly repository: string; + readonly ref: string; + readonly paths: ReadonlyArray; + }): Effect.Effect, GitLabPullRequestCliError> => + api({ + cwd: input.cwd, + path: "graphql", + method: "POST", + stdin: JSON.stringify({ + query: REPOSITORY_BLOBS_GRAPHQL_QUERY, + variables: { fullPath: input.repository, ref: input.ref, paths: input.paths }, + }), + }).pipe( + Effect.flatMap( + (result): Effect.Effect, GitLabPullRequestCliError> => { + const decoded = decodeRepositoryBlobsJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getFileRevisions", + cause: decoded.failure, + }), + ); + }, + ), + ); + + const fileRevisions = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly paths: ReadonlyArray; + }): Effect.Effect, GitLabPullRequestCliError> => + input.paths.length === 0 + ? Effect.succeed(new Map()) + : getDiffRefs(input).pipe( + Effect.flatMap((refs) => { + const batches: Array> = []; + for (let at = 0; at < input.paths.length; at += BLOB_PATHS_PER_REQUEST) { + batches.push(input.paths.slice(at, at + BLOB_PATHS_PER_REQUEST)); + } + return Effect.forEach( + batches, + (paths) => blobsAt({ ...input, ref: refs.headSha, paths }), + { concurrency: 2 }, + ); + }), + Effect.map((pages) => new Map(pages.flatMap((page) => [...page]))), + ); + const viewerUsername = (input: { readonly cwd: string }) => api({ cwd: input.cwd, path: "user" }).pipe( Effect.flatMap((result): Effect.Effect => { @@ -1061,6 +1138,8 @@ export const make = Effect.gen(function* () { listReactions: (input) => awardsPage({ ...input, cursor: null, page: 1, collected: null }), + getFileRevisions: fileRevisions, + setReaction: (input) => Effect.gen(function* () { const subject = awardSubjectPath(input); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts index 701ef53b08ec..17788910bc45 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -35,6 +35,11 @@ const CAPABILITIES: PullRequestCapabilities = { updateMethods: ["rebase"], search: true, reactions: true, + // GitLab keeps a reader's viewed files in one browser's local storage, where nothing outside + // that browser can read or write them. So the marks made here are this environment's own: they + // follow the reader between the clients connected to it, but they are not the ones gitlab.com + // shows, and the surface says so rather than implying a review can be carried on from there. + viewedFiles: "environment", review: { inlineComment: true, reply: true, @@ -222,6 +227,15 @@ export const make = Effect.gen(function* () { getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), + // What each marked file is at the head, which is what tells a mark that still stands from one + // the branch has moved past. GitLab's own local-storage marks are keyed on the blob id too, + // so this stales at the same moment its web UI would. + getFileRevisions: (input) => + cli.getFileRevisions(input).pipe( + Effect.mapError(fail("getFileRevisions")), + Effect.map((revisions) => ({ revisions })), + ), + // Users only: GitLab requests a review of a person, and the groups that can stand in for one // appear in approval rules rather than in a merge request's reviewers. listReviewerCandidates: (input) => diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 1ecba8c04224..bb5d2e135e23 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -208,6 +208,17 @@ export interface ProviderFilesViewed { readonly truncated: boolean; } +/** + * What version each of the asked-for files is at, on the change request's head. + * + * Opaque strings: the caller only ever compares one against another, and every host names a + * version its own way. A path the host answered nothing for is absent, which is the answer for a + * file the change request deletes rather than a failure to look. + */ +export interface ProviderFileRevisions { + readonly revisions: ReadonlyMap; +} + export interface ProviderRepositoryRef { readonly cwd: string; /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */ @@ -363,16 +374,17 @@ export interface PullRequestProviderApi { ) => Effect.Effect; /** - * Which files the reader has already cleared. Only called when `capabilities.viewedFiles` is - * true, and read apart from the patch: a host that reports this at all reports it on a clock of - * its own, moving with every press rather than with every push. + * Which files the reader has already cleared. Only called when the host keeps that record + * itself — `capabilities.viewedFiles` of `"host"` — and read apart from the patch: a host that + * reports this at all reports it on a clock of its own, moving with every press rather than + * with every push. */ readonly getFilesViewed?: ( input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; /** - * Clears files, or puts them back. Only called when `capabilities.viewedFiles` is true. + * Clears files, or puts them back. Only called when `capabilities.viewedFiles` is `"host"`. * * Takes several at once because that is how they are pressed. A provider whose host has no * bulk form still owes one round trip for the batch rather than one per file, since the point @@ -385,6 +397,22 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + /** + * What version the head has of each of these files. Required of a host whose + * `capabilities.viewedFiles` is `"environment"`, and unused by one that keeps the marks itself. + * + * The marks live here, but what counts as the same file does not: only the host can say whether + * what a reader cleared last week is still what is in front of them. Asked for the marked paths + * alone, so the cost follows how much of the change request has been read rather than how large + * it is. + */ + readonly getFileRevisions?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly paths: ReadonlyArray; + }, + ) => Effect.Effect; + readonly runAction: ( input: ProviderRepositoryRef & { readonly number: number; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 7903c3de6472..6882a9f6f563 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -11,6 +11,8 @@ import type { } from "@t3tools/contracts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import { @@ -154,8 +156,10 @@ function makeService(input: { readonly providers: ReadonlyArray; readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"]; }) { - return PullRequestService.make.pipe( - Effect.provide( + // Built into the test's own scope rather than provided call by call: the marks store owns a + // database, and `Effect.provide` would close it the moment the service was handed back. + return Effect.flatMap( + Layer.build( Layer.mergeAll( Layer.succeed(PullRequestProviderRegistry, fromProviders(input.providers)), Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ @@ -172,8 +176,12 @@ function makeService(input: { }), }), SourceControlRateLimit.layer, + // The real store over a database of its own, so the environment-kept marks are exercised + // through the SQL that holds them rather than through a stand-in that agrees with itself. + PullRequestFilesViewed.layer.pipe(Layer.provide(SqlitePersistenceMemory)), ), ), + (context) => Effect.provideContext(PullRequestService.make, context), ); } @@ -3440,7 +3448,7 @@ it.effect("keeps the diff cached across a file being ticked off", () => mergeMethods: ["merge"], search: true, reactions: true, - viewedFiles: true, + viewedFiles: "host", review: FULL_REVIEW, reviewers: FULL_REVIEWERS, }, @@ -3473,6 +3481,184 @@ it.effect("keeps the diff cached across a file being ticked off", () => }), ); +const environmentViewedProvider = ( + revisions: Map, + asked: Array>, +) => + fakeProvider("gitlab", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + viewedFiles: "environment", + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getFilesViewed: () => Effect.die("the host keeps no marks of its own"), + setFilesViewed: () => Effect.die("the host keeps no marks of its own"), + getFileRevisions: (input) => { + asked.push(input.paths); + return Effect.succeed({ + revisions: new Map( + input.paths.flatMap((path) => { + const revision = revisions.get(path); + return revision === undefined ? [] : [[path, revision] as const]; + }), + ), + }); + }, + }); + +const environmentViewedService = ( + revisions: Map, + asked: Array>, +) => + makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [environmentViewedProvider(revisions, asked)], + }); + +const GITLAB_REFERENCE = { + projectId: "p1" as ProjectId, + repository: "group/project", + number: 1, +}; + +it.effect("keeps viewed files itself for a host that keeps none of its own", () => + Effect.gen(function* () { + const asked: Array> = []; + const service = yield* environmentViewedService( + new Map([ + ["src/a.ts", "blob-a"], + ["src/b.ts", "blob-b"], + ]), + asked, + ); + + // Nothing marked is nothing to ask the host about. + const empty = yield* service.filesViewed(GITLAB_REFERENCE); + assert.deepStrictEqual(empty, { files: [], truncated: false }); + assert.deepStrictEqual(asked, []); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [ + { path: "src/a.ts", viewed: true }, + { path: "src/b.ts", viewed: true }, + ], + }); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual( + [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { path: "src/a.ts", state: "viewed" }, + { path: "src/b.ts", state: "viewed" }, + ], + ); + assert.strictEqual(marked.truncated, false); + // The marked paths alone, so the cost follows how much has been read rather than PR size. + assert.deepStrictEqual( + asked.map((paths) => [...paths].toSorted()), + [ + ["src/a.ts", "src/b.ts"], + ["src/a.ts", "src/b.ts"], + ], + ); + }), +); + +it.effect("reports a file pushed to since it was cleared as changed", () => + Effect.gen(function* () { + const revisions = new Map([ + ["src/a.ts", "blob-a"], + ["src/b.ts", "blob-b"], + ]); + const service = yield* environmentViewedService(revisions, []); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [ + { path: "src/a.ts", viewed: true }, + { path: "src/b.ts", viewed: true }, + ], + }); + revisions.set("src/a.ts", "blob-a-again"); + // A push is not something the marks can hear about, so the reader asks to be re-answered. + yield* service.invalidate({ reference: GITLAB_REFERENCE }); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual( + [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { path: "src/a.ts", state: "dismissed" }, + { path: "src/b.ts", state: "viewed" }, + ], + ); + }), +); + +it.effect("clears a mark again when the file is put back", () => + Effect.gen(function* () { + const asked: Array> = []; + const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: false }], + }); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual(marked.files, []); + // Unticking asks the host nothing: the row is going away whatever the head has. + assert.deepStrictEqual(asked, [["src/a.ts"]]); + }), +); + +it.effect("keeps a deleted file cleared, which the head has no version of at all", () => + Effect.gen(function* () { + const service = yield* environmentViewedService(new Map(), []); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/gone.ts", viewed: true }], + }); + yield* service.invalidate({ reference: GITLAB_REFERENCE }); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual(marked.files, [{ path: "src/gone.ts", state: "viewed" }]); + }), +); + +it.effect("keeps environment marks apart from another change request's", () => + Effect.gen(function* () { + const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), []); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + const other = yield* service.filesViewed({ ...GITLAB_REFERENCE, number: 2 }); + + assert.deepStrictEqual(other.files, []); + }), +); + it.effect("refuses to track viewed files on a host that does not", () => Effect.gen(function* () { const service = yield* makeService({ diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 0467fd0f9a00..4492dab98b0b 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,6 +1,7 @@ import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -51,6 +52,7 @@ import { import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import { @@ -462,6 +464,9 @@ function withRateLimitBackoff( ...(api.setFilesViewed === undefined ? {} : { setFilesViewed: interactive("setFilesViewed", api.setFilesViewed) }), + ...(api.getFileRevisions === undefined + ? {} + : { getFileRevisions: wrap("getFileRevisions", api.getFileRevisions) }), runAction: interactive("runAction", api.runAction), ...(api.updateChangeRequest === undefined ? {} @@ -510,6 +515,7 @@ export const make = Effect.gen(function* () { const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const rateLimits = yield* SourceControlRateLimit.SourceControlRateLimit; + const filesViewedStore = yield* PullRequestFilesViewed.PullRequestFilesViewedRepository; const refineUnknownProjectKinds = ( projects: ReadonlyArray, @@ -1309,23 +1315,142 @@ export const make = Effect.gen(function* () { }), ); + /** + * Which change request's marks, and whose. The host is part of it because the same + * `owner/repo` exists on more than one install, and the reader is part of it for the reason + * a host's own record is per-account. A host that will not say who is reading leaves it + * empty, which is one reader rather than none. + */ + const filesViewedScope = (project: SupportedProject, number: number, viewer: string | null) => ({ + provider: project.api.kind, + host: project.host, + repository: project.repository, + number, + viewer: viewer ?? "", + }); + + const toFilesViewedStoreError = (operation: string) => (cause: unknown) => + new PullRequestOperationError({ + operation, + detail: "This environment could not reach its record of which files you have seen.", + cause, + }); + + /** + * What the head has of these files, or null where the host cannot say. Null is not an error: + * without it the marks simply stop reporting staleness, which is worse than the host's own + * record but better than refusing to remember anything. + */ + const fileRevisionsOf = ( + project: SupportedProject, + number: number, + paths: ReadonlyArray, + operation: string, + ): Effect.Effect | null, PullRequestError> => { + const read = project.api.getFileRevisions; + return read === undefined + ? Effect.succeed(null) + : read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number, + paths, + }).pipe( + Effect.map((answer) => answer.revisions), + Effect.mapError(toPullRequestError(operation)), + ); + }; + + /** + * The marks this environment keeps for a host that keeps none of its own. + * + * A file the head still has at the revision it was cleared at is cleared; one the head has + * moved on from is reported as changed, which is what GitHub says of a file pushed to since it + * was ticked. Revisions are asked for the marked paths alone, so the cost follows how much of + * the change request has been read rather than how large it is, and a reader who has marked + * nothing costs no host call at all. + */ + const environmentFilesViewed = ( + project: SupportedProject, + number: number, + ): Effect.Effect => + Effect.gen(function* () { + const viewer = yield* viewerOf(project); + const marks = yield* filesViewedStore + .list(filesViewedScope(project, number, viewer)) + .pipe(Effect.mapError(toFilesViewedStoreError("filesViewed"))); + if (marks.length === 0) return { files: [], truncated: false }; + const revisions = yield* fileRevisionsOf( + project, + number, + marks.map((mark) => mark.path), + "filesViewed", + ); + return { + files: marks.map((mark) => ({ + path: mark.path, + // Absent reads as the empty revision on both sides, so a file the change request + // deletes is cleared once and stays cleared rather than reporting itself changed the + // moment it is ticked. + state: + revisions === null || (revisions.get(mark.path) ?? "") === mark.revision + ? ("viewed" as const) + : ("dismissed" as const), + })), + // Every mark is a row this environment holds, so there is no page to run out of. + truncated: false, + }; + }); + + const environmentSetFilesViewed = ( + project: SupportedProject, + input: PullRequestSetFilesViewedInput, + ): Effect.Effect => + Effect.gen(function* () { + const viewer = yield* viewerOf(project); + // Only the files being cleared need a revision. An unticked one is about to lose its row, + // and what the head has of it changes nothing about deleting it. + const cleared = input.files.filter((file) => file.viewed).map((file) => file.path); + const revisions = + cleared.length === 0 + ? null + : yield* fileRevisionsOf(project, input.number, cleared, "setFilesViewed"); + const viewedAt = DateTime.formatIso(yield* DateTime.now); + yield* filesViewedStore + .set({ + ...filesViewedScope(project, input.number, viewer), + files: input.files.map((file) => ({ + path: file.path, + revision: revisions?.get(file.path) ?? "", + viewed: file.viewed, + })), + viewedAt, + }) + .pipe(Effect.mapError(toFilesViewedStoreError("setFilesViewed"))); + }); + const filesViewedUncached = (input: PullRequestRef) => requireProject(input).pipe( - Effect.flatMap((project) => { + Effect.flatMap((project): Effect.Effect => { const read = project.api.getFilesViewed; - return project.api.capabilities.viewedFiles === true && read - ? read({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number: input.number, - }).pipe(Effect.mapError(toPullRequestError("filesViewed"))) - : Effect.fail( - new PullRequestOperationError({ - operation: "filesViewed", - detail: "This host does not track which files a reader has seen.", - }), - ); + if (project.api.capabilities.viewedFiles === "host" && read) { + return read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }).pipe(Effect.mapError(toPullRequestError("filesViewed"))); + } + if (project.api.capabilities.viewedFiles === "environment") { + return environmentFilesViewed(project, input.number); + } + return Effect.fail( + new PullRequestOperationError({ + operation: "filesViewed", + detail: "This host does not track which files a reader has seen.", + }), + ); }), ); @@ -1333,20 +1458,24 @@ export const make = Effect.gen(function* () { requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { const write = project.api.setFilesViewed; - return project.api.capabilities.viewedFiles === true && write - ? write({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number: input.number, - files: input.files, - }).pipe(Effect.mapError(toPullRequestError("setFilesViewed"))) - : Effect.fail( - new PullRequestOperationError({ - operation: "setFilesViewed", - detail: "This host does not track which files a reader has seen.", - }), - ); + if (project.api.capabilities.viewedFiles === "host" && write) { + return write({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + files: input.files, + }).pipe(Effect.mapError(toPullRequestError("setFilesViewed"))); + } + if (project.api.capabilities.viewedFiles === "environment") { + return environmentSetFilesViewed(project, input); + } + return Effect.fail( + new PullRequestOperationError({ + operation: "setFilesViewed", + detail: "This host does not track which files a reader has seen.", + }), + ); }), // Deliberately not `invalidatedByMutation`: ticking a file off says nothing about the // change request, and dropping a 300-file diff on every checkbox is the whole cost of diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts index 9221c1ab8e04..6751bdd97d20 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { decodeAwardEmojiJson, + decodeRepositoryBlobsJson, decodeCommitsJson, decodeMergeRequestDetailJson, decodeMergeRequestDiffsJson, @@ -608,3 +609,69 @@ describe("gitLabAwardName", () => { expect(gitLabAwardName("hooray")).toBe("tada"); }); }); + +describe("decodeRepositoryBlobsJson", () => { + it("reads a blob id per path", () => { + const blobs = expectSuccess( + decodeRepositoryBlobsJson( + JSON.stringify({ + data: { + project: { + repository: { + blobs: { + nodes: [ + { path: "src/a.ts", oid: "aaa111" }, + { path: "src/b.ts", oid: "bbb222" }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect([...blobs]).toEqual([ + ["src/a.ts", "aaa111"], + ["src/b.ts", "bbb222"], + ]); + }); + + it("leaves out a node missing either half, which names no version", () => { + const blobs = expectSuccess( + decodeRepositoryBlobsJson( + JSON.stringify({ + data: { + project: { + repository: { + blobs: { + nodes: [ + { path: "src/a.ts", oid: null }, + { path: null, oid: "bbb222" }, + null, + { path: "src/c.ts", oid: "ccc333" }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect([...blobs]).toEqual([["src/c.ts", "ccc333"]]); + }); + + it("reads a project the reader cannot see as no blobs rather than a failure", () => { + // The revision simply has none of the asked-for files, which is what a caller reads as + // "nothing here still stands" rather than as a read that broke. + expect([ + ...expectSuccess(decodeRepositoryBlobsJson(JSON.stringify({ data: { project: null } }))), + ]).toEqual([]); + }); + + it("fails on output that is not the query's shape", () => { + expect(Result.isSuccess(decodeRepositoryBlobsJson("not json"))).toBe(false); + expect(Result.isSuccess(decodeRepositoryBlobsJson(JSON.stringify({ errors: [] })))).toBe(false); + }); +}); diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts index 9f4bd96bae08..4479ff1bfad7 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts @@ -938,3 +938,77 @@ export function decodeOwnAwardIdJson( } return Result.succeed(null); } + +/** + * What the given paths are at one revision, as blob ids. + * + * Asked for by path rather than by walking the tree: the caller already knows which files it + * cares about, and GitLab charges this query by how many paths it is given. A path the revision + * does not have comes back missing rather than as an error, which is the answer for a file the + * merge request deletes. + */ +export const REPOSITORY_BLOBS_GRAPHQL_QUERY = `query($fullPath: ID!, $ref: String!, $paths: [String!]!) { + project(fullPath: $fullPath) { + repository { + blobs(ref: $ref, paths: $paths) { + nodes { path oid } + } + } + } +}`; + +const RawRepositoryBlobsSchema = Schema.Struct({ + data: Schema.Struct({ + project: Schema.NullOr( + Schema.Struct({ + repository: Schema.optional( + Schema.NullOr( + Schema.Struct({ + blobs: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + path: Schema.optional(Schema.NullOr(Schema.String)), + oid: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + ), + ), + }), + ), + ), + }), + ), + ), + }), + ), + }), +}); + +const decodeRepositoryBlobs = decodeJsonResult(RawRepositoryBlobsSchema); + +/** + * Blob ids by path. A node without both is left out: half an answer names no version, and the + * caller reads an absent path as "the revision does not have this file". + */ +export function decodeRepositoryBlobsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeRepositoryBlobs(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const blobs = new Map(); + for (const node of decoded.success.data.project?.repository?.blobs?.nodes ?? []) { + const path = trimmed(node?.path); + const oid = trimmed(node?.oid); + if (path === null || oid === null) continue; + blobs.set(path, oid); + } + return Result.succeed(blobs); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a9a2c3fa10d6..9b167350e906 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -619,7 +619,11 @@ const buildAppUnderTest = (options?: { ); const servedRoutesLayer = HttpRouter.serve( - makeRoutesLayer.pipe(Layer.provide(serviceLauncherClientLayer)), + // Viewed-file marks for a host that keeps none of its own are rows, so the routes want a + // database. Its own, in memory: nothing here shares a table with the auth store. + makeRoutesLayer.pipe( + Layer.provide(Layer.mergeAll(serviceLauncherClientLayer, SqlitePersistenceMemory)), + ), { disableListenLog: true, disableLogger: true, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d5bebe3d5000..c4081e62caae 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -27,6 +27,7 @@ import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; @@ -447,6 +448,8 @@ const commandReadinessLayer = HttpRouter.middleware( const PullRequestServiceLive = PullRequestService.layer.pipe( // One registry entry per supported host; the service only knows the registry. Layer.provide(PullRequestProviderRegistry.layer), + // Where the viewed-file marks live for a host that keeps none of its own. + Layer.provide(PullRequestFilesViewed.layer), Layer.provide(SourceControlProviderRegistryLayerLive), Layer.provide(SourceControlRateLimit.layer), Layer.provide(VcsProcess.layer), diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 23978183e825..1d5f8fe891b0 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -16,6 +16,7 @@ import { ChevronsDownUpIcon, ChevronsUpDownIcon, Columns2Icon, + InfoIcon, MessageSquareIcon, MessageSquareOffIcon, Rows3Icon, @@ -394,12 +395,13 @@ export function PullRequestCodeTab({ ); const filePaths = useMemo(() => files.map((file) => resolveFileDiffPath(file)), [files]); // Offered under a commit scope as well as from the whole change, because reading a change one - // commit at a time is what the scope is for. The tick itself stays the host's: it is kept - // against the change request, so clearing a file here clears it everywhere. + // commit at a time is what the scope is for. The tick is kept against the change request rather + // than the scope it was made in, so clearing a file here clears it everywhere. + const viewedFilesStore = detail.capabilities.viewedFiles; const filesViewed = usePullRequestFilesViewed({ environmentId, reference, - enabled: detail.capabilities.viewedFiles === true, + enabled: viewedFilesStore !== undefined, paths: filePaths, }); const { setViewed, refresh: refreshFilesViewed } = filesViewed; @@ -1146,6 +1148,21 @@ export function PullRequestCodeTab({ {filesViewed.enabled && files.length > 0 ? ( {filesViewed.viewedCount} / {files.length} viewed + {viewedFilesStore === "environment" ? ( + + }> + + + + This host keeps no shared record of which files you have read, so these ticks + are kept by this environment. They follow you between the apps connected to it, + but the host's own web UI will not show them. + + + ) : null} {filesViewed.truncated ? ( }> diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index 573b823b1170..f3c638ee5dba 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -28,10 +28,10 @@ const FLUSH_DELAY_MS = 400; const NO_OVERLAY: FileViewedOverlay = new Map(); export interface PullRequestFilesViewedView { - /** Whether the host tracks this at all, which is what hides the whole control. */ + /** Whether anything remembers this at all, which is what hides the whole control. */ readonly enabled: boolean; readonly isViewed: (path: string) => boolean; - /** The host says this file has been pushed to since it was cleared. */ + /** This file has been pushed to since it was cleared. */ readonly isStale: (path: string) => boolean; readonly setViewed: (path: string, viewed: boolean) => void; /** How many of the files on screen are ticked off. */ @@ -46,11 +46,13 @@ export interface PullRequestFilesViewedView { } /** - * Which files this reader has already cleared, as the host records it. + * Which files this reader has already cleared. * - * The state lives on the host rather than here so a review carried on from another machine, or - * from the host's own web UI, picks up where it was left. Presses show immediately and are held - * over the host's answer until it agrees with them, so the checkbox never waits on a round trip. + * The marks live on the server rather than in this tab, so a review carried on from another + * machine picks up where it was left. Where the host keeps a record of its own, those are the + * marks, and its web UI shows the same ones; where it does not, the environment keeps them and + * says so. Presses show immediately and are held over the server's answer until it agrees with + * them, so the checkbox never waits on a round trip. */ export function usePullRequestFilesViewed(options: { readonly environmentId: EnvironmentId; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 1bf50e0cd1a1..e7f64142e042 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -62,8 +62,12 @@ T3 Code works with the platforms your team already uses: where you left it on the next, and in your browser too - If a file is pushed to after you cleared it, it comes back marked **Changed** so you know to look again -- GitHub only. GitLab, Bitbucket, and Azure DevOps do not keep this, so the checkbox is not shown - there +- On GitHub, the ticks are the ones GitHub keeps, so a review carries on between T3 Code and + github.com in either direction +- On GitLab, they are kept by the T3 Code server you are connected to, because GitLab only + remembers them in one browser's own storage. They still follow you between the apps connected to + that server, but GitLab's own site will not show them. An info icon beside the count says so +- Bitbucket and Azure DevOps do not keep this at all, so the checkbox is not shown there - Scope the **Code** tab to a single commit and the checkboxes stay, so you can read a change one commit at a time. A tick belongs to the pull request, not to the commit, so a file you clear there is cleared everywhere diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 598c7caf18ad..9f7335acc23f 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -348,6 +348,19 @@ export const PullRequestReviewerCapabilities = Schema.Struct({ }); export type PullRequestReviewerCapabilities = typeof PullRequestReviewerCapabilities.Type; +/** + * Who remembers which files a reader has cleared. + * + * `host` is the host's own record, so the marks are the ones its web UI shows and a review can be + * carried on from either side. `environment` is this server's record, for a host that keeps no + * shared one: GitLab holds its viewed files in one browser's local storage, where nothing outside + * that browser can read or write them, so marks made here are this environment's own. They still + * follow the reader between the clients connected to it, which is more than the host manages, but + * they are not the host's and the surface says so. + */ +export const PullRequestViewedFilesStore = Schema.Literals(["host", "environment"]); +export type PullRequestViewedFilesStore = typeof PullRequestViewedFilesStore.Type; + /** * What a provider can actually do, so a surface can hide what is missing rather than offer an * action that would fail. Every provider fills this in for itself; nothing is assumed. @@ -385,15 +398,17 @@ export const PullRequestCapabilities = Schema.Struct({ */ reactions: Schema.optional(Schema.Boolean), /** - * A file can be marked as read by the person reading it, and the mark taken back. Optional for - * the same reason as `reactions`: a server that says nothing about it has none, which is what - * every server before this field was. + * Where the reader's own marks are kept, or absent where they are kept nowhere and the + * checkbox is not offered at all. Optional for the same reason as `reactions`: a server that + * says nothing about it has none, which is what every server before this field was. * - * True on GitHub alone so far. The others expose no equivalent, and a checkbox whose mark is - * forgotten the moment the tab closes is worse than no checkbox: it looks like the one beside - * it and keeps none of its promises. + * Two answers rather than a flag, because the surface has to say which one it is. A mark the + * host keeps is the same mark its own web UI shows; a mark this environment keeps is not, and + * a reader who ticks twenty files here and then opens the host would find none of them ticked. + * A checkbox that looks the same either way and quietly means different things is the failure + * this whole feature exists to avoid. */ - viewedFiles: Schema.optional(Schema.Boolean), + viewedFiles: Schema.optional(PullRequestViewedFilesStore), review: PullRequestReviewCapabilities, reviewers: PullRequestReviewerCapabilities, /** From 53253a2056312806304210b79b4d7731f66abf1d Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 10:06:25 -0400 Subject: [PATCH 15/78] fix(web): a GitLab reader can tell whose viewed marks these are The only signal that GitLab's own site would never show these ticks was a tooltip on a small icon, and the first reader to use it went looking for the marks on gitlab.com instead. The count says it now. Signed-off-by: Yordis Prieto --- apps/web/src/components/pullRequest/PullRequestCodeTab.tsx | 6 +++++- docs/user/source-control.md | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 1d5f8fe891b0..9fce1ed1647e 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -43,6 +43,7 @@ import { resolveFileDiffPreviousPath, type RenderablePatch, } from "~/lib/diffRendering"; +import { APP_BASE_NAME } from "~/branding"; import { cn } from "~/lib/utils"; import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; import { @@ -1147,7 +1148,10 @@ export function PullRequestCodeTab({ {filesViewed.enabled && files.length > 0 ? ( - {filesViewed.viewedCount} / {files.length} viewed + {/* Named on a host that keeps no record of its own, so the reader is told whose + ticks these are without having to find the icon beside them. */} + {filesViewed.viewedCount} / {files.length}{" "} + {viewedFilesStore === "environment" ? `viewed in ${APP_BASE_NAME}` : "viewed"} {viewedFilesStore === "environment" ? ( }> diff --git a/docs/user/source-control.md b/docs/user/source-control.md index e7f64142e042..7c266a19ca39 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -66,7 +66,8 @@ T3 Code works with the platforms your team already uses: github.com in either direction - On GitLab, they are kept by the T3 Code server you are connected to, because GitLab only remembers them in one browser's own storage. They still follow you between the apps connected to - that server, but GitLab's own site will not show them. An info icon beside the count says so + that server, but GitLab's own site will not show them. The count reads **viewed in T3 Code** so + you can tell at a glance, and an info icon beside it explains why - Bitbucket and Azure DevOps do not keep this at all, so the checkbox is not shown there - Scope the **Code** tab to a single commit and the checkboxes stay, so you can read a change one commit at a time. A tick belongs to the pull request, not to the commit, so a file you clear From 1de680df62357882345f4965bc4f0bb4571dae01 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 14:54:09 -0400 Subject: [PATCH 16/78] fix(web): Azure DevOps pull request links open in the app The server derives a ref's repository from the project identity, and Azure DevOps is the one host where that is not the recorded path. Clients spelled it the other way, so the server turned their refs away at the door and the link fell through to the browser. The rule now lives where both sides can read it. Signed-off-by: Yordis Prieto --- .../src/pullRequest/PullRequestService.ts | 25 +++------ apps/web/src/components/ChatMarkdown.tsx | 3 +- apps/web/src/components/ChatView.tsx | 3 +- apps/web/src/lib/openPullRequestLink.test.ts | 22 ++++++++ apps/web/src/lib/openPullRequestLink.ts | 13 +++-- packages/contracts/src/pullRequest.test.ts | 53 +++++++++++++++++++ packages/contracts/src/pullRequest.ts | 27 ++++++++++ packages/shared/src/git.test.ts | 32 +++++++++++ packages/shared/src/git.ts | 38 ++++++++++--- 9 files changed, 183 insertions(+), 33 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 4492dab98b0b..5b4f0b0c79ae 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -11,6 +11,7 @@ import { PullRequestUnavailableError, pullRequestHostOf, pullRequestProviderRequirement, + pullRequestRepositoryOf, resolvePullRequestAuthorFilter, type OrchestrationProjectShell, type PullRequestAction, @@ -487,27 +488,13 @@ function withRateLimitBackoff( } /** - * The provider-native repository selector. `displayName` is the full path below the host, which - * is what nested GitLab groups need; owner/name is the two-segment fallback for identities - * recorded before that field existed. - * - * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and - * takes the organisation and project from the checkout it detects — so the recorded - * `org/project/_git/repo` path is refused outright and the whole repository reads as - * unavailable. Its name is the last segment, which is what this hands over. - * - * One function because everything downstream is keyed by what it answers: the rows' own - * `repository`, the per-repository cursors, and the detail and diff reads a row leads to. + * The provider-native repository selector for a project, which everything downstream is keyed by: + * the rows' own `repository`, the per-repository cursors, and the detail and diff reads a row + * leads to. The rule itself is shared with the clients that build a ref, since a ref spelled any + * other way is refused before it reaches a provider. */ export function repositoryIdentityOf(project: OrchestrationProjectShell): string | null { - const identity = project.repositoryIdentity; - if (!identity) return null; - if (identity.provider === "azure-devops") { - const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); - return identity.name || segments.at(-1) || null; - } - if (identity.displayName) return identity.displayName; - return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; + return pullRequestRepositoryOf(project.repositoryIdentity); } export const make = Effect.gen(function* () { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index dd21a4e1bf40..18f9dbaa4ba4 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -19,6 +19,7 @@ import type { ServerProviderSkill, ThreadLinkedPullRequest, } from "@t3tools/contracts"; +import { pullRequestRepositoryOf } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -1761,7 +1762,7 @@ function ChatMarkdown({ if (project === undefined) return null; return { projectId: project.id, - repository: project.repositoryIdentity?.displayName ?? parsed.repository, + repository: pullRequestRepositoryOf(project.repositoryIdentity) ?? parsed.repository, number: parsed.number, url: href, }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f0188af478c0..d5e53154bb1a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -19,6 +19,7 @@ import { OrchestrationThreadActivity, ProviderInteractionMode, ProviderDriverKind, + pullRequestRepositoryOf, RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; @@ -3476,7 +3477,7 @@ function ChatViewContent(props: ChatViewProps) { // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null; - const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; + const activeProjectRepository = pullRequestRepositoryOf(activeProject?.repositoryIdentity); const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; const openThreadPullRequest = useCallback( (number: number) => { diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index c783f0fcc766..7b9bafb650ea 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -242,6 +242,28 @@ describe("findProjectForChangeRequest", () => { ).toBeUndefined(); }); + it("matches an Azure repository cloned over SSH, whose remote shares no part with its URL", () => { + // Azure alone addresses one repository under two names: `ssh.dev.azure.com` and `v3/...` over + // SSH against `dev.azure.com` and `.../_git/...` everywhere a person sees it. The identity is + // recorded in the spelling a link arrives in, so both halves of this comparison line up. + const projects = [ + project({ + canonicalKey: "dev.azure.com/t3tools/platform/_git/t3code", + provider: "azure-devops", + displayName: "t3tools/platform/_git/t3code", + owner: "t3tools", + name: "t3code", + }), + ]; + expect( + findProjectForChangeRequest(projects, { + host: "dev.azure.com", + repository: "t3tools/platform/_git/t3code", + number: 1, + }), + ).toBe(projects[0]); + }); + it("claims nothing for a lookalike host, which is what keeps a link a link", () => { const projects = [ project({ diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 22ee2938a032..888e3e8339d5 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -8,7 +8,11 @@ import { useNavigate } from "@tanstack/react-router"; import * as Schema from "effect/Schema"; import { type MouseEvent, useCallback } from "react"; -import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; +import { + pullRequestHostOf, + pullRequestRepositoryOf, + type SourceControlProviderKind, +} from "@t3tools/contracts"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { readLocalApi } from "../localApi"; @@ -262,9 +266,10 @@ export function useOpenChangeRequestLink( if (resolvedThreadRef) { useRightPanelStore.getState().openPullRequest(resolvedThreadRef, { projectId: project.id, - // The identity's own spelling, not the one read out of the URL: the panel asks the - // provider for this repository, while matching a link only ever compares lower case. - repository: project.repositoryIdentity?.displayName ?? parsed.repository, + // The selector the server derives from the same identity, not the one read out of the + // URL: a ref spelled any other way is refused before it reaches a provider, and + // matching a link only ever compares lower case. + repository: pullRequestRepositoryOf(project.repositoryIdentity) ?? parsed.repository, number: parsed.number, }); return true; diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index 4e54ca308a78..f96e755d3576 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -7,6 +7,7 @@ import { PullRequestListInput, PullRequestListResult, PullRequestReviewerRequestInput, + pullRequestRepositoryOf, resolvePullRequestAuthorFilter, } from "./pullRequest.ts"; @@ -230,3 +231,55 @@ describe("naming the reader as the author to narrow by", () => { expect(resolvePullRequestAuthorFilter("me", " ")).toBe("me"); }); }); + +describe("the repository a ref names", () => { + const identity = (fields: Record) => + ({ canonicalKey: "example.test/repo", locator: {}, ...fields }) as never; + + it("names an Azure DevOps repository by itself, not by the project path around it", () => { + // `az repos pr list --repository` takes a name and detects the organisation and project from + // the checkout; handed the recorded path it refuses, and the repository reads as unavailable. + expect( + pullRequestRepositoryOf( + identity({ + provider: "azure-devops", + displayName: "contoso/payments/_git/checkout", + owner: "contoso", + name: "checkout", + }), + ), + ).toBe("checkout"); + }); + + it("falls back to the path's last segment where an Azure identity has no name", () => { + expect( + pullRequestRepositoryOf( + identity({ provider: "azure-devops", displayName: "contoso/payments/_git/checkout" }), + ), + ).toBe("checkout"); + }); + + it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => { + expect( + pullRequestRepositoryOf( + identity({ + provider: "gitlab", + displayName: "group/subgroup/service", + owner: "group", + name: "service", + }), + ), + ).toBe("group/subgroup/service"); + }); + + it("puts owner and name back together for an identity recorded before displayName", () => { + expect( + pullRequestRepositoryOf(identity({ provider: "github", owner: "t3tools", name: "t3code" })), + ).toBe("t3tools/t3code"); + }); + + it("names nothing for a project with no remote to name it by", () => { + expect(pullRequestRepositoryOf(null)).toBeNull(); + expect(pullRequestRepositoryOf(identity({ provider: "github" }))).toBeNull(); + }); +}); diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 9f7335acc23f..c065837df8af 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -9,6 +9,7 @@ import { ProjectId, TrimmedNonEmptyString, } from "./baseSchemas.ts"; +import { type RepositoryIdentity } from "./environment.ts"; import { SourceControlProviderKind } from "./sourceControl.ts"; export const PullRequestInvolvement = Schema.Literals(["all", "reviewing", "authored"]); @@ -615,6 +616,32 @@ export const PullRequestRef = Schema.Struct({ }); export type PullRequestRef = typeof PullRequestRef.Type; +/** + * The `repository` a {@link PullRequestRef} carries, read off the project's recorded identity. + * + * `displayName` is the full path below the host, which is what nested GitLab groups need; + * owner/name is the two-segment fallback for identities recorded before that field existed. + * + * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and + * takes the organisation and project from the checkout it detects, so the recorded + * `org/project/_git/repo` path is refused outright and the whole repository reads as + * unavailable. Its name is the last segment, which is what this hands over. + * + * Shared rather than server-only because the server checks a ref's `repository` against the one + * it derives here, so a client that spells it any other way is turned away at the door. + */ +export function pullRequestRepositoryOf( + identity: RepositoryIdentity | null | undefined, +): string | null { + if (!identity) return null; + if (identity.provider === "azure-devops") { + const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); + return identity.name || segments.at(-1) || null; + } + if (identity.displayName) return identity.displayName; + return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; +} + /** * 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 diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 8dea20f0b423..c3f1c024fe6f 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -49,6 +49,38 @@ describe("normalizeGitRemoteUrl", () => { "bitbucket.org/workspace/repo", ); }); + + it("gives an Azure DevOps repository the same key over SSH as over HTTPS", () => { + expect(normalizeGitRemoteUrl("git@ssh.dev.azure.com:v3/T3Tools/Platform/T3Code")).toBe( + "dev.azure.com/t3tools/platform/_git/t3code", + ); + expect(normalizeGitRemoteUrl("ssh://git@ssh.dev.azure.com:22/v3/T3Tools/Platform/T3Code")).toBe( + "dev.azure.com/t3tools/platform/_git/t3code", + ); + expect( + normalizeGitRemoteUrl("https://T3Tools@dev.azure.com/T3Tools/Platform/_git/T3Code"), + ).toBe("dev.azure.com/t3tools/platform/_git/t3code"); + }); + + it("puts the organization back in the host on the name dev.azure.com replaced", () => { + expect( + normalizeGitRemoteUrl("T3Tools@vs-ssh.visualstudio.com:v3/T3Tools/Platform/T3Code"), + ).toBe("t3tools.visualstudio.com/platform/_git/t3code"); + expect(normalizeGitRemoteUrl("https://T3Tools.visualstudio.com/Platform/_git/T3Code")).toBe( + "t3tools.visualstudio.com/platform/_git/t3code", + ); + }); + + it("leaves an Azure SSH host it cannot read as the path it was given", () => { + // Not `v3`, and not four segments: rewriting either would invent a repository that the web + // spelling has no name for, so the remote stands as it arrived. + expect(normalizeGitRemoteUrl("git@ssh.dev.azure.com:v4/T3Tools/Platform/T3Code")).toBe( + "ssh.dev.azure.com/v4/t3tools/platform/t3code", + ); + expect(normalizeGitRemoteUrl("git@ssh.dev.azure.com:v3/T3Tools/T3Code")).toBe( + "ssh.dev.azure.com/v3/t3tools/t3code", + ); + }); }); describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 7c088970d583..4b5107cf51bc 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -108,6 +108,26 @@ export function isTemporaryWorktreeBranch(refName: string): boolean { return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase()); } +/** + * The web spelling of an Azure DevOps repository reached over SSH, or null for anything else. + * + * Azure alone addresses one repository under two names that share no part: `ssh.dev.azure.com` and + * `v3/{org}/{project}/{repo}` over SSH, against `dev.azure.com` and `{org}/{project}/_git/{repo}` + * everywhere a person sees it. A project cloned over SSH would otherwise be a different repository + * to every comparison made against a pull request URL, which arrives in the web spelling. So the + * web spelling is the one both are keyed by. + */ +function azureDevOpsRepositoryKey(host: string, segments: ReadonlyArray): string | null { + if (host !== "ssh.dev.azure.com" && host !== "vs-ssh.visualstudio.com") return null; + const [marker, organization, project, repository] = segments; + if (segments.length !== 4 || marker !== "v3") return null; + if (!organization || !project || !repository) return null; + // The organization leads the host on the name dev.azure.com replaced, and the path below it. + return host === "ssh.dev.azure.com" + ? `dev.azure.com/${organization}/${project}/_git/${repository}` + : `${organization}.visualstudio.com/${project}/_git/${repository}`; +} + /** * Normalize a git remote URL into a stable comparison key. */ @@ -121,12 +141,12 @@ export function normalizeGitRemoteUrl(value: string): string { if (/^(?:ssh|https?|git):\/\//i.test(normalized)) { try { const url = new URL(normalized); - const repositoryPath = url.pathname - .split("/") - .filter((segment) => segment.length > 0) - .join("/"); - if (url.hostname && repositoryPath.includes("/")) { - return `${url.hostname}/${repositoryPath}`; + const repositorySegments = url.pathname.split("/").filter((segment) => segment.length > 0); + if (url.hostname && repositorySegments.length > 1) { + return ( + azureDevOpsRepositoryKey(url.hostname, repositorySegments) ?? + `${url.hostname}/${repositorySegments.join("/")}` + ); } } catch { return normalized; @@ -136,8 +156,10 @@ export function normalizeGitRemoteUrl(value: string): string { const scpStyleHostAndPath = /^[a-zA-Z0-9._-]+@([^:/\s]+):([^/\s]+(?:\/[^/\s]+)+)$/i.exec( normalized, ); - if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) { - return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`; + const scpHost = scpStyleHostAndPath?.[1]; + const scpPath = scpStyleHostAndPath?.[2]; + if (scpHost && scpPath) { + return azureDevOpsRepositoryKey(scpHost, scpPath.split("/")) ?? `${scpHost}/${scpPath}`; } return normalized; From 087641590693352afa64e4ea340169e0984c62cc Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 14:54:15 -0400 Subject: [PATCH 17/78] feat(server): Bitbucket reviewers keep their place in a long review Bitbucket records nothing about what a reviewer has already read, so the marks are this environment's own. Without a revision to compare against they could not tell a file still as it was read from one pushed to since, which is the distinction that makes the marks worth keeping at all. Signed-off-by: Yordis Prieto --- .../BitbucketPullRequestApi.test.ts | 81 +++++++++++ .../pullRequest/BitbucketPullRequestApi.ts | 101 ++++++++++--- .../BitbucketPullRequestProvider.ts | 17 +++ .../bitbucketDiffRevisions.test.ts | 133 ++++++++++++++++++ .../src/pullRequest/bitbucketDiffRevisions.ts | 98 +++++++++++++ 5 files changed, 411 insertions(+), 19 deletions(-) create mode 100644 apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts create mode 100644 apps/server/src/pullRequest/bitbucketDiffRevisions.ts diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index 8945ecc5e1e2..7cdec6dc9a92 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -362,6 +362,87 @@ layer("BitbucketPullRequestApi.layer", (it) => { }), ); + it.effect("reads file versions out of the patch, for the paths it was asked about", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + [ + "diff --git a/a.ts b/a.ts", + "index 1111111..2222222 100644", + "--- a/a.ts", + "+++ b/a.ts", + "@@ -1 +1 @@", + "-a", + "+b", + "diff --git a/b.ts b/b.ts", + "index 3333333..4444444 100644", + "--- a/b.ts", + "+++ b/b.ts", + "@@ -1 +1 @@", + "-c", + "+d", + "", + ].join("\n"), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const revisions = yield* api.getFileRevisions({ + repository: "acme/web", + number: 71, + paths: ["a.ts", "missing.ts"], + }); + + // `b.ts` is in the patch and was not asked about, and `missing.ts` was asked about and is + // not in the patch. Neither belongs in the answer. + assert.deepStrictEqual([...revisions], [["a.ts", "2222222"]]); + expect(callAt(0)).toMatchObject({ url: "/repositories/acme/web/pullrequests/71/diff" }); + }), + ); + + it.effect("re-reads the patch once for a burst of presses rather than once per press", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response("diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n"), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const first = yield* api.getFileRevisions({ + repository: "acme/web", + number: 72, + paths: ["a.ts"], + }); + const second = yield* api.getFileRevisions({ + repository: "acme/web", + number: 72, + paths: ["a.ts"], + }); + + assert.deepStrictEqual([...first], [["a.ts", "2222222"]]); + assert.deepStrictEqual([...second], [["a.ts", "2222222"]]); + assert.strictEqual(mockedRequest.mock.calls.length, 1); + }), + ); + + it.effect("asks Bitbucket nothing when no file has been ticked off", () => + Effect.gen(function* () { + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const revisions = yield* api.getFileRevisions({ + repository: "acme/web", + number: 73, + paths: [], + }); + + assert.strictEqual(revisions.size, 0); + assert.strictEqual(mockedRequest.mock.calls.length, 0); + }), + ); + it.effect("aggregates every diffstat page", () => Effect.gen(function* () { const next = "https://api.bitbucket.org/2.0/diffstat?page=2"; diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index 5b3149b0d75c..b841210b6a6f 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -1,5 +1,8 @@ +import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; +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 Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -19,6 +22,7 @@ import type { } from "@t3tools/contracts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; +import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts"; import { buildReviewThreads, decodeCommentsJson, @@ -135,6 +139,14 @@ const CONVERSATION_PAGE_SIZE = 50; const CONVERSATION_PAGES = 10; /** The same ceiling the gh and glab diff reads use. */ const DIFF_MAX_BYTES = 8 * 1024 * 1024; +/** + * How long the versions read out of a patch stand for. The same window the diff itself is held + * for, deliberately: the patch on screen and what it is said to be at must not disagree, and a + * reader ticking their way down a file list would otherwise re-read the whole patch per press. + */ +const FILE_REVISIONS_CACHE_TTL = Duration.seconds(60); +/** Pull requests held at once, which is more than anyone has open. */ +const FILE_REVISIONS_CACHE_CAPACITY = 32; export interface BitbucketPullRequestBatch { readonly items: ReadonlyArray; @@ -182,6 +194,19 @@ export class BitbucketPullRequestApi extends Context.Service< readonly number: number; }) => Effect.Effect; + /** + * What the pull request's head has of each of these paths, as opaque ids. + * + * Read off the pull request's own patch, the only place Bitbucket states a file's version, and + * held briefly so that ticking files off does not re-read it per press. Paths the patch says + * nothing about are left out. + */ + readonly getFileRevisions: (input: { + readonly repository: string; + readonly number: number; + readonly paths: ReadonlyArray; + }) => Effect.Effect, BitbucketPullRequestApiError>; + readonly getMergeability: (input: { readonly repository: string; readonly number: number; @@ -524,6 +549,47 @@ export const make = Effect.gen(function* () { }), ); + const pullRequestDiff = (input: { + readonly repository: string; + readonly number: number; + readonly commit?: string | undefined; + }): Effect.Effect< + { readonly patch: string; readonly truncated: boolean }, + BitbucketPullRequestApiError + > => + input.commit !== undefined && !isCommitSha(input.commit) + ? Effect.fail(new BitbucketDiffCommitError()) + : withRepository(input.repository, (path) => + // Already a unified patch, so it needs no decoding at all — only a bound, which a + // diff of any size would otherwise ignore. A commit's own patch sits beside the pull + // request's at `/diff/{sha}` and reads the same way. + bitbucket + .request({ + method: "GET", + url: + input.commit === undefined + ? `${path}/pullrequests/${input.number}/diff` + : `${path}/diff/${input.commit}`, + maxBytes: DIFF_MAX_BYTES, + }) + .pipe( + Effect.map((response) => ({ patch: response.body, truncated: response.truncated })), + ), + ); + + const fileRevisionsCache = yield* Cache.makeWith( + (key: string) => { + const [repository, number] = JSON.parse(key) as [string, number]; + return pullRequestDiff({ repository, number }).pipe( + Effect.map((diff) => parseDiffFileRevisions(diff.patch)), + ); + }, + { + capacity: FILE_REVISIONS_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? FILE_REVISIONS_CACHE_TTL : Duration.zero), + }, + ); + return BitbucketPullRequestApi.of({ getViewer: () => bitbucket.request({ method: "GET", url: "/user" }).pipe( @@ -595,25 +661,22 @@ export const make = Effect.gen(function* () { }), ).pipe(Effect.catchIf(isRepositoryPermissionRemovedError, () => Effect.succeed(true))), - getPullRequestDiff: (input) => - input.commit !== undefined && !isCommitSha(input.commit) - ? Effect.fail(new BitbucketDiffCommitError()) - : withRepository(input.repository, (path) => - // Already a unified patch, so it needs no decoding at all — only a bound, which a - // diff of any size would otherwise ignore. A commit's own patch sits beside the pull - // request's at `/diff/{sha}` and reads the same way. - bitbucket - .request({ - method: "GET", - url: - input.commit === undefined - ? `${path}/pullrequests/${input.number}/diff` - : `${path}/diff/${input.commit}`, - maxBytes: DIFF_MAX_BYTES, - }) - .pipe( - Effect.map((response) => ({ patch: response.body, truncated: response.truncated })), - ), + getPullRequestDiff: pullRequestDiff, + + getFileRevisions: (input) => + input.paths.length === 0 + ? Effect.succeed(new Map()) + : Cache.get(fileRevisionsCache, JSON.stringify([input.repository, input.number])).pipe( + Effect.map((all) => { + // Narrowed to what was asked for rather than handed back whole: the caller compares + // the paths it named, and a patch of a thousand files has no business in its answer. + const asked = new Map(); + for (const path of input.paths) { + const revision = all.get(path); + if (revision !== undefined) asked.set(path, revision); + } + return asked; + }), ), getDiffStat: (input) => diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index e7a9a6b6ddd9..93c7f0610757 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -31,6 +31,11 @@ const CAPABILITIES: PullRequestCapabilities = { }, reviewers: { request: true, listCandidates: true }, edit: { changeRequest: true, comment: true }, + // Bitbucket Cloud states nothing about what a reviewer has already read: no endpoint carries a + // viewed file, and the per-pull-request properties it does offer are one value shared by + // everyone rather than one per reader. So the marks are kept here, and the client says whose + // they are rather than implying bitbucket.org will show them. + viewedFiles: "environment", }; /** @@ -233,6 +238,18 @@ export const make = Effect.gen(function* () { Effect.map((diff) => ({ ...diff, nextCursor: null })), ), + getFileRevisions: (input) => + api + .getFileRevisions({ + repository: input.repository, + number: input.number, + paths: input.paths, + }) + .pipe( + Effect.mapError(fail("getFileRevisions")), + Effect.map((revisions) => ({ revisions })), + ), + // Users only: Bitbucket requests a review of an account, and has no group that stands in for // one on a pull request. listReviewerCandidates: (input) => diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts b/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts new file mode 100644 index 000000000000..572548309b75 --- /dev/null +++ b/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts @@ -0,0 +1,133 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts"; + +function patchOf(...lines: ReadonlyArray): string { + return `${lines.join("\n")}\n`; +} + +describe("parseDiffFileRevisions", () => { + it("reads the head id of a changed file off its index line", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/src/a.ts b/src/a.ts", + "index 7f2aa0ab6..b4a2a7c9a 100644", + "--- a/src/a.ts", + "+++ b/src/a.ts", + "@@ -1 +1 @@", + "-a", + "+b", + ), + ); + + assert.deepStrictEqual([...revisions], [["src/a.ts", "b4a2a7c9a"]]); + }); + + it("names a deletion by the path it had, which is the one still on screen", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/gone.ts b/gone.ts", + "deleted file mode 100644", + "index 1111111..0000000", + "--- a/gone.ts", + "+++ /dev/null", + "@@ -1 +0,0 @@", + "-x", + ), + ); + + assert.deepStrictEqual([...revisions], [["gone.ts", "0000000"]]); + }); + + it("names a rename by where it moved to", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/old.ts b/new.ts", + "similarity index 90%", + "rename from old.ts", + "rename to new.ts", + "index 2222222..3333333 100644", + "--- a/old.ts", + "+++ b/new.ts", + "@@ -1 +1 @@", + "-a", + "+b", + ), + ); + + assert.deepStrictEqual([...revisions], [["new.ts", "3333333"]]); + }); + + it("names a rename that changed nothing, which states no paths of its own", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/old.ts b/new.ts", + "similarity index 100%", + "rename from old.ts", + "rename to new.ts", + "index 2222222..2222222 100644", + ), + ); + + assert.deepStrictEqual([...revisions], [["new.ts", "2222222"]]); + }); + + it("leaves out a file Bitbucket excluded, which it gives no index line for", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/package.json b/package.json", + "index 7f2aa0ab6..b4a2a7c9a 100644", + "--- a/package.json", + "+++ b/package.json", + "@@ -1 +1 @@", + '- "x": "1"', + '+ "x": "2"', + "diff --git a/yarn.lock b/yarn.lock", + 'File excluded by pattern "yarn.lock"', + ), + ); + + assert.deepStrictEqual([...revisions], [["package.json", "b4a2a7c9a"]]); + }); + + it("stops reading headers at the first hunk, so content cannot pose as one", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/notes.md b/notes.md", + "index aaaaaaa..bbbbbbb 100644", + "--- a/notes.md", + "+++ b/notes.md", + "@@ -1,2 +1,2 @@", + "--- a/decoy.ts", + "+++ b/decoy.ts", + "+index ccccccc..ddddddd 100644", + ), + ); + + assert.deepStrictEqual([...revisions], [["notes.md", "bbbbbbb"]]); + }); + + it("splits a header whose paths contain the separator, by the sides agreeing", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/one b/two.ts b/one b/two.ts", + "index eeeeeee..fffffff 100644", + "@@ -1 +1 @@", + ), + ); + + assert.deepStrictEqual([...revisions], [["one b/two.ts", "fffffff"]]); + }); + + it("leaves out an added file that Bitbucket sent no index line for", () => { + const revisions = parseDiffFileRevisions( + patchOf("diff --git a/added.ts b/added.ts", "new file mode 100644", "--- /dev/null"), + ); + + assert.strictEqual(revisions.size, 0); + }); + + it("reads nothing out of an empty patch", () => { + assert.strictEqual(parseDiffFileRevisions("").size, 0); + }); +}); diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts new file mode 100644 index 000000000000..af915bc9e069 --- /dev/null +++ b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts @@ -0,0 +1,98 @@ +const ENTRY = "diff --git "; + +interface Entry { + oldPath: string | null; + newPath: string | null; + deleted: boolean; + revision: string | null; + /** Past the first hunk header every line is content, and content can start like a header. */ + inBody: boolean; +} + +/** `a/x` and `b/x` on a `---` or `+++` line; `/dev/null` is the side that has no file. */ +function sidePath(rest: string, prefix: string): string | null { + if (rest === "/dev/null") return null; + return rest.startsWith(prefix) ? rest.slice(prefix.length) : rest; +} + +/** + * The two names on a `diff --git` line, which git writes with no delimiter between them. + * + * `a/one two b/one two` splits in more than one place, so the split that leaves both sides equal + * wins. A rename is the only entry whose sides differ, and a rename states its names on lines of + * its own. Anything still ambiguous is left unnamed rather than guessed at. + */ +function headerPaths(rest: string): readonly [string | null, string | null] { + if (!rest.startsWith("a/")) return [null, null]; + const splits: Array = []; + for (let at = rest.indexOf(" b/"); at !== -1; at = rest.indexOf(" b/", at + 1)) splits.push(at); + const chosen = + splits.find((at) => rest.slice(2, at) === rest.slice(at + 3)) ?? + (splits.length === 1 ? splits[0] : undefined); + return chosen === undefined ? [null, null] : [rest.slice(2, chosen), rest.slice(chosen + 3)]; +} + +/** The right-hand id of `index .. `. */ +function headRevision(rest: string): string | null { + const gap = rest.indexOf(".."); + if (gap === -1) return null; + const after = rest.slice(gap + 2); + const end = after.indexOf(" "); + const head = end === -1 ? after : after.slice(0, end); + return head.length === 0 ? null : head; +} + +/** + * What the head has of each file in a unified patch, as the blob ids git writes into it. + * + * Bitbucket states a file's version nowhere else: its diffstat entries carry a commit and a path + * and no blob id, and no endpoint answers what a file is now. Git's own `index ..` + * line is in the patch the diff already reads, so the versions cost no call of their own. + * + * Keyed the way the client names files: the head's name for it, except for a deletion, where the + * head has no name and the one it had is what is on screen. An entry the patch gives no `index` + * line for, one Bitbucket excluded by pattern most often, is left out. Left out reads the same + * way when a file is ticked and when the tick is read back, so the mark still holds. + */ +export function parseDiffFileRevisions(patch: string): ReadonlyMap { + const revisions = new Map(); + let entry: Entry | null = null; + + const close = () => { + if (entry === null) return; + const path = entry.deleted ? entry.oldPath : (entry.newPath ?? entry.oldPath); + if (path !== null && path.length > 0 && entry.revision !== null) { + revisions.set(path, entry.revision); + } + entry = null; + }; + + for (const line of patch.split("\n")) { + if (line.startsWith(ENTRY)) { + close(); + const [oldPath, newPath] = headerPaths(line.slice(ENTRY.length)); + entry = { oldPath, newPath, deleted: false, revision: null, inBody: false }; + continue; + } + if (entry === null || entry.inBody) continue; + if (line.startsWith("@@")) { + entry.inBody = true; + } else if (line.startsWith("index ")) { + entry.revision = headRevision(line.slice("index ".length)); + } else if (line.startsWith("deleted file mode")) { + entry.deleted = true; + } else if (line.startsWith("rename from ")) { + entry.oldPath = line.slice("rename from ".length); + } else if (line.startsWith("rename to ")) { + entry.newPath = line.slice("rename to ".length); + } else if (line.startsWith("--- ")) { + entry.oldPath = sidePath(line.slice(4), "a/"); + } else if (line.startsWith("+++ ")) { + const side = sidePath(line.slice(4), "b/"); + entry.newPath = side; + if (side === null) entry.deleted = true; + } + } + close(); + return revisions; +} From 9146a44b7c88fa88c2c32a260b87ba22d849b1f1 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 14:54:25 -0400 Subject: [PATCH 18/78] feat(server): Azure DevOps pull requests show their files The adapter never read what a pull request changed, so the panel reported no files and the Code tab was hidden outright. Azure serves no patch of its own, and its record of what a reviewer has read sits behind an endpoint it has never released, so both are answered from what it does state: the files an iteration changed, and the blob each side holds. Reading the conversation moved off `az rest` in the process. It mints its own token against whichever tenant `az` defaults to, which is not the one an organisation necessarily lives in, so that read had been failing wherever the two differ. Signed-off-by: Yordis Prieto --- apps/server/package.json | 1 + .../AzureDevOpsPullRequestCli.test.ts | 113 ++++++- .../pullRequest/AzureDevOpsPullRequestCli.ts | 169 +++++++++-- .../AzureDevOpsPullRequestProvider.ts | 284 +++++++++++++++--- .../src/pullRequest/azureDevOpsDiff.test.ts | 148 +++++++++ .../server/src/pullRequest/azureDevOpsDiff.ts | 139 +++++++++ .../azureDevOpsPullRequestJson.test.ts | 141 ++++++++- .../pullRequest/azureDevOpsPullRequestJson.ts | 185 +++++++++++- docs/user/source-control.md | 13 +- pnpm-lock.yaml | 3 + 10 files changed, 1102 insertions(+), 94 deletions(-) create mode 100644 apps/server/src/pullRequest/azureDevOpsDiff.test.ts create mode 100644 apps/server/src/pullRequest/azureDevOpsDiff.ts diff --git a/apps/server/package.json b/apps/server/package.json index 4d17229cd3af..307a6c311d59 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -30,6 +30,7 @@ "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", + "diff": "8.0.3", "effect": "catalog:", "msgpackr-extract": "3.0.4", "node-pty": "^1.1.0", diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index 5baf18a1ff6a..d6da118936bc 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -476,6 +476,105 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect("names the head's blob as what a cleared file was cleared at", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + { + id: 2, + sourceRefCommit: { commitId: "c".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + changeEntries: [ + { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } }, + { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, + ], + }), + ), + ), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + // Kept here rather than on Azure: its own record of what a reader has read is behind an + // undocumented endpoint, so the marks belong to this environment and need a revision of + // their own to tell a re-push from a file still as it was read. + assert.strictEqual(provider.capabilities.viewedFiles, "environment"); + assert.isDefined(provider.getFileRevisions); + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["README.md"], + }); + + // The latest push, since an iteration's changes are reported against the merge base rather + // than against the push before it. + expect(argsOfCall(2)).toContain("iterationId=2"); + // Only what was asked for. DEMO.md changed too, and nobody has marked it. + expect([...answer.revisions]).toEqual([["README.md", "8f80"]]); + }), + ); + + it.effect("leaves out a marked file the pull request no longer changes", () => + Effect.gen(function* () { + // Which reads as the empty revision, the same thing stored for a file that had none when it + // was ticked. A file the pull request deletes is cleared once and stays cleared. + mockedExecute.mockReturnValue(Effect.succeed(output('{"changeEntries":[]}'))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.getFileRevisions); + + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: [], + }); + + expect(answer.revisions.size).toBe(0); + // Nothing was marked, so Azure was not asked at all. + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + it.effect("reads the conversation through the REST API, pinned to a version", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( @@ -499,14 +598,18 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { const comments = yield* cli.listThreads({ cwd: "/w", - threadsUrl: "https://dev.azure.com/acme/platform/_apis/git/r/web/pullRequests/42/threads", + location: { project: "platform", repository: "web" }, + number: 42, }); assert.strictEqual(comments.length, 1); - expect(argsOfCall(0)).toContain("rest"); - expect(argsOfCall(0)).toContain( - "https://dev.azure.com/acme/platform/_apis/git/r/web/pullRequests/42/threads?api-version=7.1", - ); + // `az devops invoke` rather than `az rest`: it signs in the way the azure-devops extension + // does, and `az rest` mints its own token against whichever tenant `az` defaults to. + expect(argsOfCall(0)).toContain("invoke"); + expect(argsOfCall(0)).toContain("pullRequestThreads"); + expect(argsOfCall(0)).toContain("project=platform"); + expect(argsOfCall(0)).toContain("repositoryId=web"); + expect(argsOfCall(0)).toContain("pullRequestId=42"); }), ); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 549a172b3646..a77bba0adf6b 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -13,11 +13,17 @@ import type { import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; import { + decodeItemContentJson, + decodeIterationChangesJson, + decodeIterationsJson, decodePullRequestJson, decodePullRequestListJson, decodeThreadsJson, decodeViewerJson, + type AzureDevOpsChangeEntry, + type AzureDevOpsIteration, type AzureDevOpsPullRequest, + type AzureDevOpsRepositoryLocation, } from "./azureDevOpsPullRequestJson.ts"; import type { ProviderListCursor } from "./PullRequestProvider.ts"; @@ -149,9 +155,42 @@ export class AzureDevOpsPullRequestCli extends Context.Service< /** Threads are not reachable through `az repos pr`, so they come from the REST API. */ readonly listThreads: (input: { readonly cwd: string; - readonly threadsUrl: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + /** + * The pushes a pull request has had, oldest first. Azure hangs the changed files off an + * iteration rather than off the pull request, so reading a diff starts here. + */ + readonly listIterations: (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + + /** + * What one iteration changed, against the merge base rather than against the previous push, + * which is the whole of the pull request rather than the latest slice of it. + */ + readonly listIterationChanges: (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + readonly iterationId: number; + }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + + /** + * One file's text at one commit. Azure has no diff route that carries content, so both sides + * of every changed file are read this way and the patch is made from them here. + */ + readonly readItemContent: (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly path: string; + readonly commit: string; + }) => Effect.Effect; + readonly runPullRequestAction: (input: { readonly cwd: string; readonly number: number; @@ -264,6 +303,68 @@ export const make = Effect.gen(function* () { args: [...input.args, "--only-show-errors", "--output", "json"], }); + /** + * A REST route reached through `az devops invoke`, which addresses it by area, resource and + * route parameters rather than by URL. It is used in place of `az rest` because it signs in the + * way the azure-devops extension does, and `az rest` mints its own token against the tenant `az` + * defaults to. For an organisation in any other tenant that token is rejected and Azure answers + * with a sign-in page, which arrives here as unreadable output rather than as a failure. + */ + const invoke = (input: { + readonly cwd: string; + readonly operation: string; + readonly resource: string; + readonly routeParameters: ReadonlyArray; + readonly queryParameters?: ReadonlyArray; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + executeJson({ + cwd: input.cwd, + args: [ + "devops", + "invoke", + ...detectArgs, + "--area", + "git", + "--resource", + input.resource, + "--api-version", + REST_API_VERSION, + "--route-parameters", + ...input.routeParameters, + ...(input.queryParameters === undefined + ? [] + : ["--query-parameters", ...input.queryParameters]), + ], + }).pipe( + Effect.flatMap((result) => { + const decoded = input.decode(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: input.operation, + cause: decoded.failure, + }), + ); + }), + ); + + const repositoryRoute = (location: AzureDevOpsRepositoryLocation): ReadonlyArray => [ + `project=${location.project}`, + `repositoryId=${location.repository}`, + ]; + + const pullRequestRoute = (input: { + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + }): ReadonlyArray => [ + ...repositoryRoute(input.location), + `pullRequestId=${input.number}`, + ]; + /** * Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and * retain the raw count so the next public cursor skips every row this walk consumed. @@ -430,30 +531,52 @@ export const make = Effect.gen(function* () { ), listThreads: (input) => - executeJson({ + invoke({ + cwd: input.cwd, + operation: "listThreads", + resource: "pullRequestThreads", + routeParameters: pullRequestRoute(input), + decode: decodeThreadsJson, + }), + + listIterations: (input) => + invoke({ + cwd: input.cwd, + operation: "listIterations", + resource: "pullRequestIterations", + routeParameters: pullRequestRoute(input), + decode: decodeIterationsJson, + }), + + listIterationChanges: (input) => + invoke({ + cwd: input.cwd, + operation: "listIterationChanges", + resource: "pullRequestIterationChanges", + routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`], + // Azure pages this route at 1000 entries by default. A review that large is already past + // what the client will render, and the ceiling is Azure's own maximum for the route. + queryParameters: ["$top=2000"], + decode: decodeIterationChangesJson, + }), + + readItemContent: (input) => + invoke({ cwd: input.cwd, - args: [ - "rest", - "--method", - "get", - "--url", - `${input.threadsUrl}?api-version=${REST_API_VERSION}`, + operation: "readItemContent", + resource: "items", + routeParameters: repositoryRoute(input.location), + queryParameters: [ + `path=${input.path}`, + "versionDescriptor.versionType=commit", + `versionDescriptor.version=${input.commit}`, + "includeContent=true", + // Without this Azure answers with the file's own bytes rather than with a JSON + // envelope, and `az devops invoke` refuses anything it cannot parse as JSON. + "$format=json", ], - }).pipe( - Effect.flatMap((result) => { - const decoded = decodeThreadsJson(result.stdout.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new AzureDevOpsPullRequestReadError({ - command: "az", - cwd: input.cwd, - operation: "listThreads", - cause: decoded.failure, - }), - ); - }), - ), + decode: decodeItemContentJson, + }), setPullRequestReviewers: (input) => input.reviewers.some((reviewer) => !isReviewerName(reviewer)) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 631fee971cc1..1fe7f60f66fb 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -2,20 +2,33 @@ import * as Effect from "effect/Effect"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import { + azureDevOpsFilePatch, + formatAzureDevOpsDiffCursor, + parseAzureDevOpsDiffCursor, + MAX_DIFF_SLICE_BYTES, + type AzureDevOpsFileTexts, +} from "./azureDevOpsDiff.ts"; import { PullRequestProviderError, type PullRequestProviderFailure, type ProviderChangeRequest, type ProviderChangeRequestActivity, type ProviderChangeRequestDetail, + type ProviderDiffSlice, type PullRequestProviderApi, } from "./PullRequestProvider.ts"; -import type { AzureDevOpsPullRequest } from "./azureDevOpsPullRequestJson.ts"; +import type { + AzureDevOpsChangeEntry, + AzureDevOpsIteration, + AzureDevOpsPullRequest, + AzureDevOpsRepositoryLocation, +} from "./azureDevOpsPullRequestJson.ts"; const CAPABILITIES: PullRequestCapabilities = { - // `az repos pr` has no diff command, and the REST route reports changed files without their - // contents, so there is no patch to show. The Code tab is hidden rather than empty. - diff: false, + // Azure serves no patch of its own, so the one the Code tab reads is built here out of the + // files an iteration changed and both sides of each of them. + diff: true, // Reading a conversation is a plain REST read, but posting one is not something this can // claim without having run it, so the composer stays hidden. comment: false, @@ -33,7 +46,8 @@ const CAPABILITIES: PullRequestCapabilities = { // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. search: false, reactions: false, - // With no patch to show there are no lines to write against, so nothing here is offered. + // The patch has lines to write against, but writing a remark at all is what Azure is not + // offered for here, so nothing in a review is either. review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos` // lists the ones this repository could name — that lives behind the identity and graph APIs, a @@ -44,6 +58,11 @@ const CAPABILITIES: PullRequestCapabilities = { // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on // Azure DevOps at all, so there is nothing here it could rewrite either. edit: { changeRequest: true, comment: false }, + // Azure does keep a viewed record of its own, but only behind the undocumented contribution + // endpoint its web UI talks to, keyed on an iteration so a push would drop every mark anyway. + // So they are kept here instead, and the client says whose they are rather than implying the + // Azure DevOps page will show them. + viewedFiles: "environment", }; /** @@ -85,8 +104,8 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq state: pullRequest.state, isDraft: pullRequest.isDraft, mergeability: pullRequest.mergeability, - // Azure reports no line counts on a pull request, and with no patch to read there is - // nothing to count them from either. + // Azure counts a pull request's files but never its lines, and counting them here would mean + // reading every file on both sides of every row of a listing. additions: 0, deletions: 0, createdAt: pullRequest.createdAt, @@ -121,6 +140,82 @@ export const make = Effect.gen(function* () { }), ); + /** A pull request Azure could not place has no diff to read, which reads as an empty one. */ + const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null }; + + /** + * Everything a diff read needs before it can ask for a file: where the repository lives, and + * which pushes the pull request has had. A client names neither, and the pull request read is + * the only place Azure states the first. + */ + const diffScope = (input: { readonly cwd: string; readonly number: number }) => + Effect.gen(function* () { + const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }); + const location = pullRequest.location; + if (location === null) return null; + const iterations = yield* cli.listIterations({ + cwd: input.cwd, + location, + number: input.number, + }); + return { location, iterations }; + }); + + /** + * Both sides of one changed file. Only the sides a change actually has are asked for: Azure + * answers for a file that is not at a commit with a failure rather than with nothing. + */ + const readTexts = (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly iteration: AzureDevOpsIteration; + readonly change: Pick; + }) => + Effect.gen(function* () { + const oldContents = + input.change.changeKind === "new" + ? "" + : yield* cli.readItemContent({ + cwd: input.cwd, + location: input.location, + path: input.change.oldPath, + commit: input.iteration.mergeBaseCommit, + }); + const newContents = + input.change.changeKind === "deleted" + ? "" + : yield* cli.readItemContent({ + cwd: input.cwd, + location: input.location, + path: input.change.path, + commit: input.iteration.headCommit, + }); + const texts: AzureDevOpsFileTexts = { oldContents, newContents }; + return texts; + }); + + /** + * What the whole pull request changed, taken from its latest push. An iteration's changes are + * reported against the merge base rather than against the push before it, so the newest one is + * the whole of the change rather than the last slice of it. + */ + const listLatestChanges = (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + readonly iterations: ReadonlyArray; + }) => { + const latest = input.iterations.at(-1); + return latest === undefined + ? Effect.succeed([] as ReadonlyArray) + : cli.listIterationChanges({ + cwd: input.cwd, + location: input.location, + number: input.number, + iterationId: latest.id, + }); + }; + const provider: PullRequestProviderApi = { kind: "azure-devops", capabilities: CAPABILITIES, @@ -155,34 +250,57 @@ export const make = Effect.gen(function* () { ), getChangeRequest: (input) => - cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( - Effect.mapError(fail("getChangeRequest")), - Effect.map( - (pullRequest): ProviderChangeRequestDetail => ({ - ...toChangeRequest(pullRequest), - body: pullRequest.body, - changedFiles: 0, - mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, - closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, - reviewers: pullRequest.reviewers, - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: false }, - viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, - autoMergeEnabled: pullRequest.autoMergeEnabled, - }), - ), - ), + Effect.gen(function* () { + const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }); + const location = pullRequest.location; + // The file count is two reads past the pull request itself, and it is the only thing + // riding on them, so a failure leaves it unknown rather than losing the whole detail. + const changedFiles = + location === null + ? 0 + : yield* cli.listIterations({ cwd: input.cwd, location, number: input.number }).pipe( + Effect.flatMap((iterations) => + listLatestChanges({ + cwd: input.cwd, + location, + number: input.number, + iterations, + }), + ), + Effect.map((changes) => changes.length), + Effect.orElseSucceed(() => 0), + ); + const detail: ProviderChangeRequestDetail = { + ...toChangeRequest(pullRequest), + body: pullRequest.body, + changedFiles, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + reviewers: pullRequest.reviewers, + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: false }, + viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, + autoMergeEnabled: pullRequest.autoMergeEnabled, + }; + return detail; + }).pipe(Effect.mapError(fail("getChangeRequest"))), getChangeRequestActivity: (input) => cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( Effect.mapError(fail("getChangeRequestActivity")), Effect.flatMap((pullRequest) => - (pullRequest.threadsUrl === null + (pullRequest.location === null ? Effect.succeed({ comments: [], truncated: true }) - : cli.listThreads({ cwd: input.cwd, threadsUrl: pullRequest.threadsUrl }).pipe( - Effect.map((comments) => ({ comments, truncated: false })), - Effect.orElseSucceed(() => ({ comments: [], truncated: true })), - ) + : cli + .listThreads({ + cwd: input.cwd, + location: pullRequest.location, + number: input.number, + }) + .pipe( + Effect.map((comments) => ({ comments, truncated: false })), + Effect.orElseSucceed(() => ({ comments: [], truncated: true })), + ) ).pipe( Effect.map( (conversation): ProviderChangeRequestActivity => ({ @@ -201,16 +319,104 @@ export const make = Effect.gen(function* () { // reach, so the answer is the same constant the detail carries. getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS), - // Never called: `capabilities.diff` is false, and the service refuses a diff without it. - getDiff: () => - Effect.fail( - new PullRequestProviderError({ - provider: "azure-devops", - operation: "getDiff", - reason: "failed", - detail: "Azure DevOps cannot produce a patch for a pull request.", - }), - ), + // `input.commit` is deliberately dropped: Azure states no commit list on a pull request, so + // the Code tab has nothing to scope itself to and always asks for the whole change. + getDiff: (input) => + Effect.gen(function* () { + const scope = yield* diffScope(input); + if (scope === null) return EMPTY_DIFF_SLICE; + const cursor = parseAzureDevOpsDiffCursor(input.cursor); + // Reading on stays with the push the first slice was taken against. A push landing + // mid-read would otherwise renumber the files and hand the reader one twice, or none. + const iteration = + cursor === null + ? scope.iterations.at(-1) + : scope.iterations.find((candidate) => candidate.id === cursor.iterationId); + if (iteration === undefined) return EMPTY_DIFF_SLICE; + const changes = yield* cli.listIterationChanges({ + cwd: input.cwd, + location: scope.location, + number: input.number, + iterationId: iteration.id, + }); + + const sections: string[] = []; + let truncated = false; + let bytes = 0; + let index = cursor?.fileIndex ?? 0; + while (index < changes.length) { + const change = changes.at(index); + if (change === undefined) break; + const texts = yield* readTexts({ + cwd: input.cwd, + location: scope.location, + iteration, + change, + }); + const file = azureDevOpsFilePatch({ change, texts }); + sections.push(file.section); + bytes += file.section.length; + truncated = truncated || file.truncated; + index += 1; + if (bytes >= MAX_DIFF_SLICE_BYTES) break; + } + + const slice: ProviderDiffSlice = { + patch: sections.join(""), + truncated, + nextCursor: + index >= changes.length + ? null + : formatAzureDevOpsDiffCursor({ iterationId: iteration.id, fileIndex: index }), + }; + return slice; + }).pipe(Effect.mapError(fail("getDiff"))), + + // The patch is built from whole files, so opening the lines around a hunk is the same two + // reads over again rather than a wider request. + getDiffFileContents: (input) => + Effect.gen(function* () { + const scope = yield* diffScope(input); + const iteration = scope?.iterations.at(-1); + if (scope === null || iteration === undefined) return { oldContents: "", newContents: "" }; + return yield* readTexts({ + cwd: input.cwd, + location: scope.location, + iteration, + change: { + changeKind: input.changeType, + path: input.newPath, + oldPath: input.oldPath, + }, + }); + }).pipe(Effect.mapError(fail("getDiffFileContents"))), + + /** + * What the head has of each marked file, which is the blob Azure already names on the change + * it reports. One read covers every path: the latest iteration lists the whole change, so + * asking per file would be the same answer fetched over and over. + * + * A path the change no longer carries is left out rather than guessed at, which reads as the + * empty revision and leaves a file the pull request deletes cleared once and cleared for good. + */ + getFileRevisions: (input) => + Effect.gen(function* () { + const revisions = new Map(); + if (input.paths.length === 0) return { revisions }; + const scope = yield* diffScope(input); + if (scope === null) return { revisions }; + const changes = yield* listLatestChanges({ + ...scope, + cwd: input.cwd, + number: input.number, + }); + const marked = new Set(input.paths); + for (const change of changes) { + if (!marked.has(change.path) || change.objectId === null) continue; + revisions.set(change.path, change.objectId); + } + return { revisions }; + }).pipe(Effect.mapError(fail("getFileRevisions"))), runAction: (input) => cli diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts new file mode 100644 index 000000000000..1beba81e3660 --- /dev/null +++ b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + azureDevOpsFilePatch, + formatAzureDevOpsDiffCursor, + parseAzureDevOpsDiffCursor, +} from "./azureDevOpsDiff.ts"; +import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; + +function change(overrides: Partial = {}): AzureDevOpsChangeEntry { + return { + path: "README.md", + oldPath: "README.md", + changeKind: "change", + objectId: "8f80", + originalObjectId: "0ca4", + ...overrides, + }; +} + +describe("azureDevOpsFilePatch", () => { + it("writes a changed file as the unified patch every diff viewer already reads", () => { + const patch = azureDevOpsFilePatch({ + change: change(), + texts: { oldContents: "one\ntwo\nthree\n", newContents: "one\ntwo again\nthree\n" }, + }); + + expect(patch.truncated).toBe(false); + expect(patch.section).toBe( + [ + "diff --git a/README.md b/README.md", + "--- a/README.md", + "+++ b/README.md", + "@@ -1,3 +1,3 @@", + " one", + "-two", + "+two again", + " three", + "", + ].join("\n"), + ); + }); + + it("names the side a new file does not have as /dev/null", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }), + texts: { oldContents: "", newContents: "hello\n" }, + }); + + expect(patch.section).toContain("new file mode 100644"); + expect(patch.section).toContain("--- /dev/null"); + expect(patch.section).toContain("+++ b/DEMO.md"); + // Git points the range a new file does not have at line zero, not at line one. + expect(patch.section).toContain("@@ -0,0 +1 @@"); + expect(patch.section).toContain("+hello"); + }); + + it("names the side a deleted file no longer has as /dev/null", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }), + texts: { oldContents: "gone\n", newContents: "" }, + }); + + expect(patch.section).toContain("deleted file mode 100644"); + expect(patch.section).toContain("--- a/OLD.md"); + expect(patch.section).toContain("+++ /dev/null"); + expect(patch.section).toContain("@@ -1 +0,0 @@"); + expect(patch.section).toContain("-gone"); + }); + + it("keeps the carriage returns of a file with Windows line endings", () => { + // They are part of the line rather than around it, so a patch that dropped them would ask + // the reader to look at a change that is not the one on the host. + const patch = azureDevOpsFilePatch({ + change: change(), + texts: { oldContents: "one\r\ntwo\r\n", newContents: "one\r\ntwo again\r\n" }, + }); + + expect(patch.section).toContain("-two\r"); + expect(patch.section).toContain("+two again\r"); + }); + + it("keeps a file that only moved, which has no hunks to give", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "docs/new.md", oldPath: "docs/old.md", changeKind: "rename-pure" }), + texts: { oldContents: "same\n", newContents: "same\n" }, + }); + + expect(patch.truncated).toBe(false); + expect(patch.section).toBe( + [ + "diff --git a/docs/old.md b/docs/new.md", + "rename from docs/old.md", + "rename to docs/new.md", + "--- a/docs/old.md", + "+++ b/docs/new.md", + "", + ].join("\n"), + ); + }); + + it("reports a binary file as changed rather than spelling it out", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "logo.png", oldPath: "logo.png" }), + texts: { oldContents: "PNG\u0000old", newContents: "PNG\u0000new" }, + }); + + expect(patch.truncated).toBe(true); + expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ"); + }); + + it("shows an overlong file as changed without its hunks", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "bundle.js", oldPath: "bundle.js" }), + texts: { oldContents: "a\n".repeat(400_000), newContents: "b\n".repeat(400_000) }, + }); + + expect(patch.truncated).toBe(true); + expect(patch.section).toBe( + ["diff --git a/bundle.js b/bundle.js", "--- a/bundle.js", "+++ b/bundle.js", ""].join("\n"), + ); + }); + + it("marks a file that does not end in a newline, as git does", () => { + const patch = azureDevOpsFilePatch({ + change: change(), + texts: { oldContents: "one\n", newContents: "two" }, + }); + + expect(patch.section).toContain("\\ No newline at end of file"); + }); +}); + +describe("a diff cursor", () => { + it("carries the push it was taken against back to the next slice", () => { + const cursor = formatAzureDevOpsDiffCursor({ iterationId: 3, fileIndex: 12 }); + + expect(parseAzureDevOpsDiffCursor(cursor)).toEqual({ iterationId: 3, fileIndex: 12 }); + }); + + it("reads anything it did not write as no position at all", () => { + // Which starts the read from the top rather than failing it: a cursor is the client's to + // hand back, and nothing downstream is worth refusing a whole diff over. + for (const raw of [undefined, null, "", "abc", "1", "0:4", "1:-2", "1:2:3"]) { + expect(parseAzureDevOpsDiffCursor(raw)).toBeNull(); + } + }); +}); diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts new file mode 100644 index 000000000000..97d811c8b35c --- /dev/null +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -0,0 +1,139 @@ +import { structuredPatch } from "diff"; + +import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; + +/** + * How far a diff read got, and which push it was reading. Azure hangs a pull request's changed + * files off an iteration, so the iteration travels with the position: a push landing mid-read + * would otherwise renumber the list under the cursor and hand the reader a file twice or not at + * all. + */ +export interface AzureDevOpsDiffCursor { + readonly iterationId: number; + readonly fileIndex: number; +} + +const CURSOR_SEPARATOR = ":"; + +export function formatAzureDevOpsDiffCursor(cursor: AzureDevOpsDiffCursor): string { + return `${cursor.iterationId}${CURSOR_SEPARATOR}${cursor.fileIndex}`; +} + +/** Null for anything this did not write, which starts the read from the top rather than failing. */ +export function parseAzureDevOpsDiffCursor( + raw: string | null | undefined, +): AzureDevOpsDiffCursor | null { + if (raw === null || raw === undefined) return null; + const [iteration, file, ...rest] = raw.split(CURSOR_SEPARATOR); + if (rest.length > 0) return null; + const iterationId = Number(iteration); + const fileIndex = Number(file); + if (!Number.isSafeInteger(iterationId) || iterationId <= 0) return null; + if (!Number.isSafeInteger(fileIndex) || fileIndex < 0) return null; + return { iterationId, fileIndex }; +} + +/** The two texts of one changed file, empty on whichever side the change does not have. */ +export interface AzureDevOpsFileTexts { + readonly oldContents: string; + readonly newContents: string; +} + +export interface AzureDevOpsFilePatch { + readonly section: string; + /** The file changed but its hunks are not in the section, so the patch has a hole in it. */ + readonly truncated: boolean; +} + +/** + * Beyond this a file is shown as changed without its hunks. Azure hands back whole files rather + * than a patch, so a generated bundle or a checked-in dump is paid for twice over before anything + * can be diffed, and nobody reads the result either way. + */ +const MAX_FILE_BYTES = 512 * 1024; + +/** Git's own default, and what the hunks from this repo's other hosts are already cut to. */ +const PATCH_CONTEXT_LINES = 3; + +/** + * How much patch one slice carries before the rest is left for the next one. Every file costs a + * request per side, so the read stops on what it has produced rather than on a file count: a + * hundred one-line changes are cheaper to finish than three long ones. + */ +export const MAX_DIFF_SLICE_BYTES = 256 * 1024; + +/** A NUL byte is git's own test for it, and it survives Azure's JSON envelope intact. */ +function isBinary(contents: string): boolean { + return contents.includes("\u0000"); +} + +/** + * Git points an empty range at the line before it, which is line zero for a file that is wholly + * new or wholly gone, and writes a single line as its number alone. + */ +function hunkRange(start: number, lines: number): string { + if (lines === 0) return `${start - 1},0`; + return lines === 1 ? String(start) : `${start},${lines}`; +} + +/** + * The `diff --git` preamble a viewer reads a file's identity and fate from. Azure reports no file + * mode, so the ordinary one stands in, exactly as it does for the GitHub files API here. + */ +function patchHeader(change: AzureDevOpsChangeEntry): string { + const lines = [`diff --git a/${change.oldPath} b/${change.path}`]; + if (change.changeKind === "new") lines.push("new file mode 100644"); + if (change.changeKind === "deleted") lines.push("deleted file mode 100644"); + if (change.changeKind === "rename-pure" || change.changeKind === "rename-changed") { + lines.push(`rename from ${change.oldPath}`, `rename to ${change.path}`); + } + lines.push( + `--- ${change.changeKind === "new" ? "/dev/null" : `a/${change.oldPath}`}`, + `+++ ${change.changeKind === "deleted" ? "/dev/null" : `b/${change.path}`}`, + ); + return lines.join("\n"); +} + +/** + * One file's section of a unified patch, built here because Azure has no route that carries one: + * its diff routes name the files that changed and their blob ids, and the contents are a separate + * read per side. + */ +export function azureDevOpsFilePatch(input: { + readonly change: AzureDevOpsChangeEntry; + readonly texts: AzureDevOpsFileTexts; +}): AzureDevOpsFilePatch { + const header = patchHeader(input.change); + const { oldContents, newContents } = input.texts; + + if (isBinary(oldContents) || isBinary(newContents)) { + // Git's own wording for a file it will not spell out, which every diff viewer already reads. + const binary = `Binary files a/${input.change.oldPath} and b/${input.change.path} differ`; + return { section: `${header}\n${binary}\n`, truncated: true }; + } + if (oldContents.length > MAX_FILE_BYTES || newContents.length > MAX_FILE_BYTES) { + return { section: `${header}\n`, truncated: true }; + } + + const patch = structuredPatch( + `a/${input.change.oldPath}`, + `b/${input.change.path}`, + oldContents, + newContents, + undefined, + undefined, + { context: PATCH_CONTEXT_LINES }, + ); + const hunks = patch.hunks.map((hunk) => + [ + `@@ -${hunkRange(hunk.oldStart, hunk.oldLines)} +${hunkRange(hunk.newStart, hunk.newLines)} @@`, + ...hunk.lines, + ].join("\n"), + ); + // A pure rename has no hunks to give. It is still listed, because dropping it would take the + // file out of the change altogether. + return { + section: hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`, + truncated: false, + }; +} diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts index a975c89f858c..6c94e88e9584 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts @@ -2,6 +2,9 @@ import * as Result from "effect/Result"; import { describe, expect, it } from "vite-plus/test"; import { + decodeItemContentJson, + decodeIterationChangesJson, + decodeIterationsJson, decodePullRequestJson, decodePullRequestListJson, decodeThreadsJson, @@ -159,17 +162,15 @@ describe("decodePullRequestJson", () => { ); }); - it("works out where the conversation lives from what Azure returned", () => { + it("works out where the repository lives from what Azure returned", () => { const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest()))); - expect(detail?.threadsUrl).toBe( - "https://dev.azure.com/acme/platform/_apis/git/repositories/web/pullRequests/42/threads", - ); + expect(detail?.location).toEqual({ project: "platform", repository: "web" }); }); - it("reports no conversation url when Azure said too little to build one", () => { - // A web link places the pull request, but without the REST url and repository there is - // nothing to hang a threads collection off. + it("reports no repository location when Azure said too little to name one", () => { + // A web link places the pull request, but with no repository named there is nothing to + // address the routes that read its files and its conversation. const detail = expectSuccess( decodePullRequestJson( asJson( @@ -184,7 +185,7 @@ describe("decodePullRequestJson", () => { ), ); - expect(detail?.threadsUrl).toBeNull(); + expect(detail?.location).toBeNull(); }); it("returns nothing when Azure gave no way to place the pull request at all", () => { @@ -313,3 +314,127 @@ describe("decodeThreadsJson", () => { expect(comments).toEqual([]); }); }); + +describe("decodeIterationsJson", () => { + const iteration = (id: number, head: string, base: string) => ({ + id, + sourceRefCommit: { commitId: head }, + commonRefCommit: { commitId: base }, + targetRefCommit: { commitId: base }, + }); + + it("reads every push in order, oldest first", () => { + const iterations = expectSuccess( + decodeIterationsJson( + asJson({ value: [iteration(2, "bbb", "base"), iteration(1, "aaa", "base")] }), + ), + ); + + expect(iterations.map((entry) => entry.id)).toEqual([1, 2]); + expect(iterations.at(-1)).toEqual({ id: 2, headCommit: "bbb", mergeBaseCommit: "base" }); + }); + + it("skips a push Azure could not place both ends of", () => { + // A patch is taken over a range, and an iteration missing either end names no range at all. + const iterations = expectSuccess( + decodeIterationsJson( + asJson({ + value: [{ id: 1, sourceRefCommit: { commitId: "aaa" } }, iteration(2, "bbb", "base")], + }), + ), + ); + + expect(iterations.map((entry) => entry.id)).toEqual([2]); + }); +}); + +describe("decodeIterationChangesJson", () => { + it("names each changed file without the slash Azure leads its paths with", () => { + const changes = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }, + { + changeType: "edit", + item: { path: "/README.md", objectId: "8f80", originalObjectId: "0ca4" }, + }, + { changeType: "delete", item: { path: "/OLD.md", originalObjectId: "1111" } }, + ], + }), + ), + ); + + expect(changes.map((change) => [change.path, change.changeKind])).toEqual([ + ["DEMO.md", "new"], + ["README.md", "change"], + ["OLD.md", "deleted"], + ]); + }); + + it("reads a rename as one file that moved, and says whether it also changed", () => { + const changes = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { + changeType: "rename", + sourceServerItem: "/docs/old.md", + item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, + }, + { + changeType: "edit, rename", + sourceServerItem: "/src/old.ts", + item: { path: "/src/new.ts", objectId: "bbbb", originalObjectId: "cccc" }, + }, + ], + }), + ), + ); + + expect(changes).toEqual([ + { + path: "docs/new.md", + oldPath: "docs/old.md", + changeKind: "rename-pure", + objectId: "aaaa", + originalObjectId: "aaaa", + }, + { + path: "src/new.ts", + oldPath: "src/old.ts", + changeKind: "rename-changed", + objectId: "bbbb", + originalObjectId: "cccc", + }, + ]); + }); + + it("drops the folders Azure lists alongside the files that changed", () => { + // A review shows files, and a folder has no content on either side to show for one. + const changes = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { changeType: "add", item: { path: "/docs", isFolder: true, gitObjectType: "tree" } }, + { changeType: "add", item: { path: "/docs/page.md", objectId: "dddd" } }, + ], + }), + ), + ); + + expect(changes.map((change) => change.path)).toEqual(["docs/page.md"]); + }); +}); + +describe("decodeItemContentJson", () => { + it("reads the file's text out of the envelope Azure wraps it in", () => { + expect( + expectSuccess(decodeItemContentJson(asJson({ path: "/a.md", content: "one\ntwo" }))), + ).toBe("one\ntwo"); + }); + + it("reads an empty file as empty rather than as a failure to look", () => { + expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toBe(""); + }); +}); diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts index 39ca4a551d27..bb01e578b4a3 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts @@ -11,10 +11,7 @@ import type { import { TrimmedNonEmptyString } from "@t3tools/contracts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { - azureDevOpsOrganizationBaseFromRestApiUrl, - azureDevOpsPullRequestWebUrl, -} from "../sourceControl/azureDevOpsPullRequests.ts"; +import { azureDevOpsPullRequestWebUrl } from "../sourceControl/azureDevOpsPullRequests.ts"; /** * Azure's enums are decoded as plain strings and normalized here, in the same tolerant style as @@ -108,6 +105,16 @@ const RawViewerSchema = Schema.Struct({ ), }); +/** + * Where a repository lives, in the terms Azure's REST routes address it by. They take the project + * and the repository as separate route parameters rather than as one path, so the pair travels + * together rather than as a URL that would have to be taken apart again to use. + */ +export interface AzureDevOpsRepositoryLocation { + readonly project: string; + readonly repository: string; +} + export interface AzureDevOpsPullRequest { readonly number: number; readonly title: string; @@ -128,8 +135,8 @@ export interface AzureDevOpsPullRequest { readonly body: string; readonly reviewRequestLogins: ReadonlyArray; readonly reviewers: ReadonlyArray; - /** Where this pull request's threads live, when Azure said enough to work it out. */ - readonly threadsUrl: string | null; + /** Where this pull request lives, when Azure said enough to work it out. */ + readonly location: AzureDevOpsRepositoryLocation | null; /** Whether Azure is set to complete this on its own once its policies pass. */ readonly autoMergeEnabled: boolean; } @@ -177,15 +184,16 @@ function toMergeability(value: string | null | undefined): PullRequestMergeabili } /** - * The REST collection a pull request's threads hang from. Built from what Azure returned rather - * than from the local remote, whose shape differs between the modern, legacy and SSH forms. + * Where a pull request's own repository sits. Taken from what Azure returned rather than from the + * local remote, whose shape differs between the modern, legacy and SSH forms. */ -function toThreadsUrl(raw: Schema.Schema.Type): string | null { - const base = azureDevOpsOrganizationBaseFromRestApiUrl(raw.url); +function toLocation( + raw: Schema.Schema.Type, +): AzureDevOpsRepositoryLocation | null { const project = trimmed(raw.repository?.project?.name); const repository = trimmed(raw.repository?.name); - if (base === null || project === null || repository === null) return null; - return `${base}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repository)}/pullRequests/${raw.pullRequestId}/threads`; + if (project === null || repository === null) return null; + return { project, repository }; } /** @@ -230,7 +238,7 @@ function toPullRequest( body: raw.description ?? "", reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), reviewers, - threadsUrl: toThreadsUrl(raw), + location: toLocation(raw), autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null, }; } @@ -340,3 +348,154 @@ export function decodeThreadsJson( comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), ); } + +/** + * One push's worth of a pull request. Azure records every push as an iteration and keys the whole + * review off them: the changed files, and the marks a reader leaves on those files, both hang + * from an iteration rather than from the pull request. + */ +const RawIterationSchema = Schema.Struct({ + id: Schema.Int, + sourceRefCommit: Schema.optional( + Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + commonRefCommit: Schema.optional( + Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknown) }); + +const RawChangeEntrySchema = Schema.Struct({ + changeType: Schema.optional(Schema.NullOr(Schema.String)), + sourceServerItem: Schema.optional(Schema.NullOr(Schema.String)), + item: Schema.optional( + Schema.NullOr( + Schema.Struct({ + path: Schema.optional(Schema.NullOr(Schema.String)), + objectId: Schema.optional(Schema.NullOr(Schema.String)), + originalObjectId: Schema.optional(Schema.NullOr(Schema.String)), + /** Azure marks a directory this way; a review has nothing to show for one. */ + isFolder: Schema.optional(Schema.NullOr(Schema.Boolean)), + gitObjectType: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + +const RawChangePageSchema = Schema.Struct({ changeEntries: Schema.Array(Schema.Unknown) }); + +const RawItemContentSchema = Schema.Struct({ + content: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** The head and the merge base of one iteration, which is the range its patch is taken over. */ +export interface AzureDevOpsIteration { + readonly id: number; + readonly headCommit: string; + readonly mergeBaseCommit: string; +} + +/** + * What one file did across an iteration. `oldPath` differs from `path` only for a rename, which + * Azure reports by naming the file's previous home rather than as a delete and an add. + */ +export interface AzureDevOpsChangeEntry { + readonly path: string; + readonly oldPath: string; + readonly changeKind: "new" | "deleted" | "change" | "rename-pure" | "rename-changed"; + readonly objectId: string | null; + readonly originalObjectId: string | null; +} + +const decodeIterationPage = decodeJsonResult(RawIterationPageSchema); +const decodeIterationEntry = Schema.decodeUnknownExit(RawIterationSchema); +const decodeChangePage = decodeJsonResult(RawChangePageSchema); +const decodeChangeEntry = Schema.decodeUnknownExit(RawChangeEntrySchema); +const decodeItemContent = decodeJsonResult(RawItemContentSchema); + +/** + * Azure leads a path with a slash, which is its own spelling rather than part of the name. Every + * other host, and every patch, names the same file without it. + */ +function toRepositoryPath(value: string | null | undefined): string | null { + const path = trimmed(value); + return path === null ? null : path.replace(/^\/+/, ""); +} + +/** + * Azure names a change with one word or two, and a rename arrives either alone or alongside the + * edit that came with it. Anything it has added since reads as a plain change, which shows the + * file rather than dropping it from the review. + */ +function toChangeKind( + raw: string | null | undefined, + renamed: boolean, +): AzureDevOpsChangeEntry["changeKind"] { + const parts = new Set( + (raw ?? "") + .toLowerCase() + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0), + ); + if (parts.has("delete")) return "deleted"; + if (renamed || parts.has("rename")) return parts.has("edit") ? "rename-changed" : "rename-pure"; + if (parts.has("add")) return "new"; + return "change"; +} + +export function decodeIterationsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeIterationPage(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const iterations: AzureDevOpsIteration[] = []; + for (const entry of decoded.success.value) { + const decodedIteration = decodeIterationEntry(entry); + if (Exit.isFailure(decodedIteration)) continue; + const iteration = decodedIteration.value; + const headCommit = trimmed(iteration.sourceRefCommit?.commitId); + const mergeBaseCommit = trimmed(iteration.commonRefCommit?.commitId); + // An iteration Azure cannot place both ends of names no range, and a patch needs both. + if (headCommit === null || mergeBaseCommit === null) continue; + iterations.push({ id: iteration.id, headCommit, mergeBaseCommit }); + } + return Result.succeed(iterations.toSorted((left, right) => left.id - right.id)); +} + +export function decodeIterationChangesJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeChangePage(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const changes: AzureDevOpsChangeEntry[] = []; + for (const entry of decoded.success.changeEntries) { + const decodedChange = decodeChangeEntry(entry); + if (Exit.isFailure(decodedChange)) continue; + const change = decodedChange.value; + const path = toRepositoryPath(change.item?.path); + if (path === null) continue; + // Azure lists the folders a change touched alongside the files themselves. A review shows + // files, and a folder has no content to show for either side of one. + if (change.item?.isFolder === true) continue; + if ((change.item?.gitObjectType ?? "blob").toLowerCase() !== "blob") continue; + const oldPath = toRepositoryPath(change.sourceServerItem) ?? path; + changes.push({ + path, + oldPath, + changeKind: toChangeKind(change.changeType, oldPath !== path), + objectId: trimmed(change.item?.objectId), + originalObjectId: trimmed(change.item?.originalObjectId), + }); + } + return Result.succeed(changes); +} + +/** Azure answers an absent file with an empty body rather than an error, which reads as empty. */ +export function decodeItemContentJson(raw: string): Result.Result { + const decoded = decodeItemContent(raw); + return Result.isSuccess(decoded) + ? Result.succeed(decoded.success.content ?? "") + : Result.fail(decoded.failure); +} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 7c266a19ca39..b346d2d31dbd 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -9,7 +9,7 @@ T3 Code works with the platforms your team already uses: - **GitHub** – Pull requests, repository creation, and clone integration - **GitLab** – Merge requests, repository publishing, and hosted clones - **Bitbucket** – Pull request workflows (via API token authentication) -- **Azure DevOps** – Pull request support for Microsoft-hosted repositories +- **Azure DevOps** – Pull request support for Microsoft-hosted repositories, including the file-by-file diff ## What You Can Do @@ -64,11 +64,12 @@ T3 Code works with the platforms your team already uses: again - On GitHub, the ticks are the ones GitHub keeps, so a review carries on between T3 Code and github.com in either direction -- On GitLab, they are kept by the T3 Code server you are connected to, because GitLab only - remembers them in one browser's own storage. They still follow you between the apps connected to - that server, but GitLab's own site will not show them. The count reads **viewed in T3 Code** so - you can tell at a glance, and an info icon beside it explains why -- Bitbucket and Azure DevOps do not keep this at all, so the checkbox is not shown there +- On GitLab, Bitbucket, and Azure DevOps, they are kept by the T3 Code server you are connected + to, because none of the three offers a record T3 Code can read: GitLab remembers it in one + browser's own storage, Bitbucket not at all, and Azure DevOps only inside its own web app. They + still follow you between the apps connected to that server, but the host's own site will not + show them. The count reads **viewed in T3 Code** so you can tell at a glance, and an info icon + beside it explains why - Scope the **Code** tab to a single commit and the checkboxes stay, so you can read a change one commit at a time. A tick belongs to the pull request, not to the commit, so a file you clear there is cleared everywhere diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8986f0f8586a..22068a133f26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -481,6 +481,9 @@ importers: '@pierre/diffs': specifier: 'catalog:' version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + diff: + specifier: 8.0.3 + version: 8.0.3 effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) From bbf04bb840919a8f9e485d16bf2b5ca73f876f20 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 18:34:07 -0400 Subject: [PATCH 19/78] perf(server): a review's ticks stop waiting on the host The marks are this environment's own rows and cost nothing to read, but every read of them blocked on a host call that only the Changed badge needed, and a press paid for that call twice over. On Azure, where each one is a process spawn, coming back to a review left the checkboxes empty for seconds at a time. Correcting a badge a moment late is cheaper than making a reader wait for it, so a held answer now stands while the next one is fetched. The press itself still asks outright, since it stamps what it stores. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestCli.test.ts | 73 +++++++++ .../AzureDevOpsPullRequestProvider.ts | 37 ++++- .../pullRequest/PullRequestService.test.ts | 84 ++++++++++- .../src/pullRequest/PullRequestService.ts | 140 ++++++++++++++++-- 4 files changed, 312 insertions(+), 22 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index d6da118936bc..d5ae39164548 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -553,6 +553,79 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect("reads where a pull request lives once, however often it is asked about", () => + Effect.gen(function* () { + // A pull request cannot move repositories, and the marks would otherwise pay for a whole + // pull request read every time they checked whether a file had been pushed to. + const pullRequest = Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ); + const iterations = () => + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ); + const changes = () => + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + changeEntries: [ + { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } }, + ], + }), + ), + ); + mockedExecute + .mockReturnValueOnce(pullRequest) + .mockReturnValueOnce(iterations()) + .mockReturnValueOnce(changes()) + .mockReturnValueOnce(iterations()) + .mockReturnValueOnce(changes()); + const provider = yield* AzureDevOpsPullRequestProvider.make; + const read = provider.getFileRevisions; + assert.isDefined(read); + const ask = () => + read({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["README.md"], + }); + + yield* ask(); + const again = yield* ask(); + + assert.strictEqual(mockedExecute.mock.calls.length, 5); + // The second read goes straight to the pushes, and still answers with the head's blob. + expect(argsOfCall(3)).toContain("pullRequestIterations"); + expect([...again.revisions]).toEqual([["README.md", "8f80"]]); + }), + ); + it.effect("leaves out a marked file the pull request no longer changes", () => Effect.gen(function* () { // Which reads as the empty revision, the same thing stored for a file that had none when it diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 1fe7f60f66fb..58072f00159b 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -143,15 +143,44 @@ export const make = Effect.gen(function* () { /** A pull request Azure could not place has no diff to read, which reads as an empty one. */ const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null }; + /** + * Where a pull request's repository lives, which is the route every other read of it needs and + * the one thing only the pull request itself states. A pull request cannot move between + * repositories, so it is remembered rather than re-read: the marks alone would otherwise pay for + * a whole pull request read every time they checked whether a file had been pushed to. + * + * Bounded and oldest-first, since a long-lived server sees far more pull requests than a reader + * ever has open. + */ + const LOCATION_CACHE_CAPACITY = 128; + const locations = new Map(); + + const locationOf = (input: { readonly cwd: string; readonly number: number }) => { + const key = `${input.cwd} ${input.number}`; + const held = locations.get(key); + if (held !== undefined) return Effect.succeed(held); + return cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( + Effect.map((pullRequest) => { + const location = pullRequest.location; + if (location === null) return null; + if (locations.size >= LOCATION_CACHE_CAPACITY) { + const oldest = locations.keys().next().value; + if (oldest !== undefined) locations.delete(oldest); + } + locations.set(key, location); + return location; + }), + ); + }; + /** * Everything a diff read needs before it can ask for a file: where the repository lives, and - * which pushes the pull request has had. A client names neither, and the pull request read is - * the only place Azure states the first. + * which pushes the pull request has had. A client names neither, and the iterations are read + * afresh every time because the newest one is what a push adds. */ const diffScope = (input: { readonly cwd: string; readonly number: number }) => Effect.gen(function* () { - const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }); - const location = pullRequest.location; + const location = yield* locationOf(input); if (location === null) return null; const iterations = yield* cli.listIterations({ cwd: input.cwd, diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 6882a9f6f563..970b9142c1d1 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3568,14 +3568,92 @@ it.effect("keeps viewed files itself for a host that keeps none of its own", () ], ); assert.strictEqual(marked.truncated, false); - // The marked paths alone, so the cost follows how much has been read rather than PR size. + // The marked paths alone, so the cost follows how much has been read rather than PR size, + // and the read after the press is answered from what the press already heard. assert.deepStrictEqual( asked.map((paths) => [...paths].toSorted()), + [["src/a.ts", "src/b.ts"]], + ); + }), +); + +it.effect("reads the marks without asking the host what the head has every time", () => + Effect.gen(function* () { + const asked: Array> = []; + const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + // Past the marks' own cache, so this read reaches the point where the host would be asked. + yield* TestClock.adjust("20 seconds"); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual(marked.files, [{ path: "src/a.ts", state: "viewed" }]); + assert.deepStrictEqual(asked, [["src/a.ts"]]); + }), +); + +it.effect("answers the marks from what it last heard while it asks the host again", () => + Effect.gen(function* () { + const asked: Array> = []; + const revisions = new Map([["src/a.ts", "blob-a"]]); + const service = yield* environmentViewedService(revisions, asked); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + revisions.set("src/a.ts", "blob-a-again"); + yield* TestClock.adjust("90 seconds"); + const held = yield* service.filesViewed(GITLAB_REFERENCE); + + // The push is not in this answer, because waiting for the host is the thing being avoided. + assert.deepStrictEqual(held.files, [{ path: "src/a.ts", state: "viewed" }]); + assert.strictEqual(asked.length, 2); + + yield* TestClock.adjust("20 seconds"); + const caught = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual(caught.files, [{ path: "src/a.ts", state: "dismissed" }]); + // The refresh behind the previous answer is the one that heard about the push. + assert.strictEqual(asked.length, 2); + }), +); + +it.effect("asks the host about a file it has not been asked about before", () => + Effect.gen(function* () { + const asked: Array> = []; + const service = yield* environmentViewedService( + new Map([ + ["src/a.ts", "blob-a"], + ["src/b.ts", "blob-b"], + ]), + asked, + ); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + yield* TestClock.adjust("20 seconds"); + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/b.ts", viewed: true }], + }); + yield* TestClock.adjust("20 seconds"); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual( + [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), [ - ["src/a.ts", "src/b.ts"], - ["src/a.ts", "src/b.ts"], + { path: "src/a.ts", state: "viewed" }, + { path: "src/b.ts", state: "viewed" }, ], ); + // The second press paid for its own file; the read that follows was already covered. + assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]); }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 5b4f0b0c79ae..e33988555361 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -113,6 +113,15 @@ const LIST_STATS_CACHE_TTL = Duration.seconds(60); * all only so opening a change request on two devices costs one read. */ const FILES_VIEWED_CACHE_TTL = Duration.seconds(15); +/** + * How long the head's blob for a file is believed without asking the host again, and how long a + * held answer still stands while the next one is fetched. The marks themselves are this + * environment's own rows and cost nothing to read; this is the host call behind the **Changed** + * badge alone, so a held answer costs a badge that is a minute behind rather than a stale tick. + */ +const FILE_REVISIONS_CACHE_TTL = Duration.seconds(60); +const FILE_REVISIONS_STALE_WINDOW = Duration.minutes(10); +const FILE_REVISIONS_CACHE_CAPACITY = 64; /** A diff can stay interactive while its next cached value is fetched off the critical path. */ const DIFF_STALE_WINDOW = Duration.minutes(10); /** How long one host's signed-in login is believed without asking its CLI again. */ @@ -1302,6 +1311,10 @@ export const make = Effect.gen(function* () { }), ); + const context = yield* Effect.context(); + /** Runs a refresh as its own fiber, for the reads that answer from a held value first. */ + const runFork = Effect.runForkWith(context); + /** * Which change request's marks, and whose. The host is part of it because the same * `owner/repo` exists on more than one install, and the reader is part of it for the reason @@ -1323,30 +1336,121 @@ export const make = Effect.gen(function* () { cause, }); + /** + * What the head has of the files a reader has marked, held between reads. A path that was asked + * for and is not in the answer is one the head does not carry, which the marks read as the empty + * revision — so the entry remembers what it has been asked rather than treating every miss as an + * answer it never had. + */ + interface HeldFileRevisions { + readonly at: number; + readonly asked: ReadonlySet; + readonly revisions: ReadonlyMap; + } + const heldFileRevisions = new Map(); + const refreshingFileRevisions = new Set(); + /** + * Normalised, because a reference reaches here spelled however the client spelled it while the + * project carries the remote's own spelling, and a refresh that missed by a capital would leave + * the held answer standing. + */ + const fileRevisionsScope = (projectId: string, repository: string, number: number) => + `${projectId} ${repository.trim().toLowerCase()} ${number}`; + + const recordFileRevisions = ( + scope: string, + paths: ReadonlyArray, + answer: ReadonlyMap, + ) => + Effect.map(Clock.currentTimeMillis, (at) => { + const held = heldFileRevisions.get(scope); + // Past the stale window the old entry is not worth merging into: it would carry paths + // nobody has asked about since, at revisions the head has long moved off. + const carried = + held !== undefined && at - held.at <= Duration.toMillis(FILE_REVISIONS_STALE_WINDOW) + ? held + : null; + const revisions = new Map(carried?.revisions ?? []); + const asked = new Set(carried?.asked ?? []); + for (const path of paths) { + asked.add(path); + const revision = answer.get(path); + if (revision === undefined) revisions.delete(path); + else revisions.set(path, revision); + } + heldFileRevisions.delete(scope); + if (heldFileRevisions.size >= FILE_REVISIONS_CACHE_CAPACITY) { + const oldest = heldFileRevisions.keys().next().value; + if (oldest !== undefined) heldFileRevisions.delete(oldest); + } + heldFileRevisions.set(scope, { at, asked, revisions }); + return revisions; + }); + + /** A held entry that covers every path asked for and is still worth answering from. */ + const heldFileRevisionsFor = (scope: string, paths: ReadonlyArray, now: number) => { + const held = heldFileRevisions.get(scope); + if (held === undefined) return null; + if (now - held.at > Duration.toMillis(FILE_REVISIONS_STALE_WINDOW)) return null; + return paths.every((path) => held.asked.has(path)) ? held : null; + }; + + const forgetFileRevisions = (scope: string) => { + heldFileRevisions.delete(scope); + }; + /** * What the head has of these files, or null where the host cannot say. Null is not an error: * without it the marks simply stop reporting staleness, which is worse than the host's own * record but better than refusing to remember anything. + * + * `held` answers from a value past its lifetime and fetches the next one off the critical path, + * because a badge a moment behind beats a page of ticks that will not paint until a host answers. + * `fresh` is for the press itself, which stamps what it stores and would otherwise write a + * revision the head had already moved off. */ const fileRevisionsOf = ( project: SupportedProject, number: number, paths: ReadonlyArray, operation: string, + freshness: "held" | "fresh" = "held", ): Effect.Effect | null, PullRequestError> => { const read = project.api.getFileRevisions; - return read === undefined - ? Effect.succeed(null) - : read({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number, - paths, - }).pipe( - Effect.map((answer) => answer.revisions), - Effect.mapError(toPullRequestError(operation)), + if (read === undefined) return Effect.succeed(null); + const scope = fileRevisionsScope(project.project.id, project.repository, number); + // Suspended, so a held answer costs the host nothing: a provider is free to do its work as + // the request is built rather than as the effect is run. + const fetch = Effect.suspend(() => + read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number, + paths, + }).pipe( + Effect.mapError(toPullRequestError(operation)), + Effect.flatMap((answer) => recordFileRevisions(scope, paths, answer.revisions)), + ), + ); + return Effect.flatMap(Clock.currentTimeMillis, (now) => { + const held = heldFileRevisionsFor(scope, paths, now); + if (held === null) return fetch; + if (now - held.at <= Duration.toMillis(FILE_REVISIONS_CACHE_TTL)) + return Effect.succeed(held.revisions); + if (freshness === "fresh") return fetch; + if (refreshingFileRevisions.has(scope)) return Effect.succeed(held.revisions); + // Its own fiber rather than a child: the caller has been answered and is gone before this + // lands. One at a time per change request, so a page of files costs one host read. + return Effect.sync(() => { + refreshingFileRevisions.add(scope); + runFork( + Effect.ignore(fetch).pipe( + Effect.ensuring(Effect.sync(() => refreshingFileRevisions.delete(scope))), + ), ); + }).pipe(Effect.as(held.revisions)); + }); }; /** @@ -1402,7 +1506,7 @@ export const make = Effect.gen(function* () { const revisions = cleared.length === 0 ? null - : yield* fileRevisionsOf(project, input.number, cleared, "setFilesViewed"); + : yield* fileRevisionsOf(project, input.number, cleared, "setFilesViewed", "fresh"); const viewedAt = DateTime.formatIso(yield* DateTime.now); yield* filesViewedStore .set({ @@ -1995,9 +2099,6 @@ export const make = Effect.gen(function* () { return { stats: stats.flat() }; }); - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - /** * The diff is not live-polled and is expensive enough to keep its stale-while-revalidate path. * Explicit refreshes and mutations still strand held values through the reference epoch. @@ -2279,9 +2380,18 @@ export const make = Effect.gen(function* () { // 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(); + heldFileRevisions.clear(); return; } bumpRefEpoch(input.reference); + // Not keyed by epoch, so this one is dropped by hand rather than stranded. + forgetFileRevisions( + fileRevisionsScope( + input.reference.projectId, + input.reference.repository, + input.reference.number, + ), + ); }); // A mutation's own client re-reads right after it, and every other client's next read must From cd7ccccd14032d80d31520a30bc744e2cd198179 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 19:26:56 -0400 Subject: [PATCH 20/78] fix(server): a long or unreadable Azure change still renders its diff Azure pages its change list and answers for a binary file in an encoding of its own, so a large pull request came back as part of a change presented as the whole of it, and a file whose contents az would not hand over took the rest of the slice down with it. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestCli.test.ts | 204 +++++++++++++++++- .../pullRequest/AzureDevOpsPullRequestCli.ts | 65 ++++-- .../AzureDevOpsPullRequestProvider.ts | 55 +++-- .../src/pullRequest/azureDevOpsDiff.test.ts | 56 ++++- .../server/src/pullRequest/azureDevOpsDiff.ts | 27 ++- .../azureDevOpsPullRequestJson.test.ts | 75 ++++++- .../pullRequest/azureDevOpsPullRequestJson.ts | 58 ++++- 7 files changed, 482 insertions(+), 58 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index d5ae39164548..e8a5472c45a2 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -29,6 +29,9 @@ function output(stdout: string) { }; } +/** A fixture's own shape, spelled the way `az` would answer with it. */ +const json = (value: Record) => JSON.stringify(value); + function pullRequestRows( count: number, firstNumber: number, @@ -575,7 +578,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { const iterations = () => Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ value: [ { @@ -590,7 +592,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { const changes = () => Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ changeEntries: [ { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } }, @@ -626,10 +627,203 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); - it.effect("leaves out a marked file the pull request no longer changes", () => + it.effect( + "answers for a marked file the pull request no longer changes as the empty version", + () => + Effect.gen(function* () { + // Which is what was stored for it when it was ticked with nothing on the head, so a file + // the pull request deletes is cleared once and stays cleared. + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(output('{"changeEntries":[]}'))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.getFileRevisions); + + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["GONE.md"], + }); + + expect([...answer.revisions]).toEqual([["GONE.md", ""]]); + }), + ); + + it.effect("says nothing about the files past the end of a change it gave up following", () => + Effect.gen(function* () { + // Every page is an `az` process of its own, so a change past the ceiling stops being + // followed. A path nobody looked at must not be answered for as deleted. + const entries = (from: number, count: number) => + Array.from({ length: count }, (_, index) => ({ + changeType: "edit", + item: { path: `/src/f${from + index}.ts`, objectId: `blob-${from + index}` }, + })); + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed(output(json({ changeEntries: entries(0, 5_000), nextSkip: 5_000 }))), + ) + .mockReturnValueOnce( + Effect.succeed(output(json({ changeEntries: entries(5_000, 5_000), nextSkip: 10_000 }))), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.getFileRevisions); + + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["src/f1.ts", "src/f5001.ts", "src/past-the-cut.ts"], + }); + + // The second page picks up where the first said it ended. + expect(argsOfCall(3)).toContain("$skip=5000"); + expect([...answer.revisions]).toEqual([ + ["src/f1.ts", "blob-1"], + ["src/f5001.ts", "blob-5001"], + ]); + }), + ); + + it.effect("stops following pages when one of them does not move the cursor on", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + changeEntries: [{ changeType: "edit", item: { path: "/a.ts", objectId: "8f80" } }], + nextSkip: 2_000, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + changeEntries: [{ changeType: "edit", item: { path: "/b.ts", objectId: "0ca4" } }], + nextSkip: 2_000, + }), + ), + ), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.getFileRevisions); + + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["a.ts", "b.ts"], + }); + + // Four reads and no more: a page pointing at where it already is would be read forever. + assert.strictEqual(mockedExecute.mock.calls.length, 4); + expect([...answer.revisions]).toEqual([ + ["a.ts", "8f80"], + ["b.ts", "0ca4"], + ]); + }), + ); + + it.effect("asks Azure nothing when no file has been ticked off", () => Effect.gen(function* () { - // Which reads as the empty revision, the same thing stored for a file that had none when it - // was ticked. A file the pull request deletes is cleared once and stays cleared. mockedExecute.mockReturnValue(Effect.succeed(output('{"changeEntries":[]}'))); const provider = yield* AzureDevOpsPullRequestProvider.make; assert.isDefined(provider.getFileRevisions); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index a77bba0adf6b..7d6089c5a145 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -21,6 +21,7 @@ import { decodeThreadsJson, decodeViewerJson, type AzureDevOpsChangeEntry, + type AzureDevOpsItemContent, type AzureDevOpsIteration, type AzureDevOpsPullRequest, type AzureDevOpsRepositoryLocation, @@ -118,6 +119,23 @@ 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"; +/** Azure's own ceiling for one page of an iteration's changes. */ +const CHANGE_ENTRIES_PER_PAGE = 2000; + +/** + * Where following the pages stops. Every page is an `az` process of its own, and a change this + * long is past what any reader will get through, so the read gives up rather than spending a + * minute of spawns on it. Saying so is the point: the diff reports itself as incomplete instead + * of presenting five pages as the whole change. + */ +const MAX_CHANGE_ENTRIES = 10_000; + +/** What an iteration changed, and whether following its pages reached the end of it. */ +export interface AzureDevOpsIterationChanges { + readonly changes: ReadonlyArray; + readonly truncated: boolean; +} + export class AzureDevOpsPullRequestCli extends Context.Service< AzureDevOpsPullRequestCli, { @@ -178,7 +196,7 @@ export class AzureDevOpsPullRequestCli extends Context.Service< readonly location: AzureDevOpsRepositoryLocation; readonly number: number; readonly iterationId: number; - }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + }) => Effect.Effect; /** * One file's text at one commit. Azure has no diff route that carries content, so both sides @@ -189,7 +207,7 @@ export class AzureDevOpsPullRequestCli extends Context.Service< readonly location: AzureDevOpsRepositoryLocation; readonly path: string; readonly commit: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly runPullRequestAction: (input: { readonly cwd: string; @@ -548,17 +566,38 @@ export const make = Effect.gen(function* () { decode: decodeIterationsJson, }), - listIterationChanges: (input) => - invoke({ - cwd: input.cwd, - operation: "listIterationChanges", - resource: "pullRequestIterationChanges", - routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`], - // Azure pages this route at 1000 entries by default. A review that large is already past - // what the client will render, and the ceiling is Azure's own maximum for the route. - queryParameters: ["$top=2000"], - decode: decodeIterationChangesJson, - }), + listIterationChanges: (input) => { + const page = (skip: number) => + invoke({ + cwd: input.cwd, + operation: "listIterationChanges", + resource: "pullRequestIterationChanges", + routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`], + // Azure pages this route at 1000 entries by default; this is its own maximum per page, + // and it names where the next page starts rather than answering with the whole change. + queryParameters: [`$top=${CHANGE_ENTRIES_PER_PAGE}`, `$skip=${skip}`], + decode: decodeIterationChangesJson, + }); + const from = ( + skip: number, + collected: ReadonlyArray, + ): Effect.Effect => + page(skip).pipe( + Effect.flatMap((answer) => { + const changes = [...collected, ...answer.changes]; + // A page that does not move the cursor on would be read forever, and a change this + // long is past anything a reader will get through — so the read stops and says so, + // rather than quietly presenting part of it as the whole. + if (answer.nextSkip === null || answer.nextSkip <= skip) { + return Effect.succeed({ changes, truncated: false }); + } + return changes.length >= MAX_CHANGE_ENTRIES + ? Effect.succeed({ changes, truncated: true }) + : from(answer.nextSkip, changes); + }), + ); + return from(0, []); + }, readItemContent: (input) => invoke({ diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 58072f00159b..034df758f1ba 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -4,6 +4,7 @@ import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3t import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import { azureDevOpsFilePatch, + azureDevOpsUnreadableFilePatch, formatAzureDevOpsDiffCursor, parseAzureDevOpsDiffCursor, MAX_DIFF_SLICE_BYTES, @@ -18,8 +19,10 @@ import { type ProviderDiffSlice, type PullRequestProviderApi, } from "./PullRequestProvider.ts"; +import type { AzureDevOpsIterationChanges } from "./AzureDevOpsPullRequestCli.ts"; import type { AzureDevOpsChangeEntry, + AzureDevOpsItemContent, AzureDevOpsIteration, AzureDevOpsPullRequest, AzureDevOpsRepositoryLocation, @@ -190,6 +193,8 @@ export const make = Effect.gen(function* () { return { location, iterations }; }); + const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false }; + /** * Both sides of one changed file. Only the sides a change actually has are asked for: Azure * answers for a file that is not at a commit with a failure rather than with nothing. @@ -201,25 +206,31 @@ export const make = Effect.gen(function* () { readonly change: Pick; }) => Effect.gen(function* () { - const oldContents = + const oldItem = input.change.changeKind === "new" - ? "" + ? EMPTY_ITEM : yield* cli.readItemContent({ cwd: input.cwd, location: input.location, path: input.change.oldPath, commit: input.iteration.mergeBaseCommit, }); - const newContents = + const newItem = input.change.changeKind === "deleted" - ? "" + ? EMPTY_ITEM : yield* cli.readItemContent({ cwd: input.cwd, location: input.location, path: input.change.path, commit: input.iteration.headCommit, }); - const texts: AzureDevOpsFileTexts = { oldContents, newContents }; + const texts: AzureDevOpsFileTexts = { + oldContents: oldItem.contents, + newContents: newItem.contents, + // Azure hands a file it calls binary over in an encoding of its own, so its own word on + // that is taken rather than looked for in bytes it may never have sent verbatim. + binary: oldItem.isBinary || newItem.isBinary, + }; return texts; }); @@ -236,7 +247,7 @@ export const make = Effect.gen(function* () { }) => { const latest = input.iterations.at(-1); return latest === undefined - ? Effect.succeed([] as ReadonlyArray) + ? Effect.succeed({ changes: [], truncated: false } as AzureDevOpsIterationChanges) : cli.listIterationChanges({ cwd: input.cwd, location: input.location, @@ -296,7 +307,7 @@ export const make = Effect.gen(function* () { iterations, }), ), - Effect.map((changes) => changes.length), + Effect.map((listed) => listed.changes.length), Effect.orElseSucceed(() => 0), ); const detail: ProviderChangeRequestDetail = { @@ -362,27 +373,34 @@ export const make = Effect.gen(function* () { ? scope.iterations.at(-1) : scope.iterations.find((candidate) => candidate.id === cursor.iterationId); if (iteration === undefined) return EMPTY_DIFF_SLICE; - const changes = yield* cli.listIterationChanges({ + const listed = yield* cli.listIterationChanges({ cwd: input.cwd, location: scope.location, number: input.number, iterationId: iteration.id, }); + const changes = listed.changes; const sections: string[] = []; - let truncated = false; + let truncated = listed.truncated; let bytes = 0; let index = cursor?.fileIndex ?? 0; while (index < changes.length) { const change = changes.at(index); if (change === undefined) break; + // One file per pair of reads, and a pair Azure refuses is one file rather than the + // whole slice: an oversize blob or a path `az` will not carry through leaves that file + // listed without its hunks, and everything around it still renders. const texts = yield* readTexts({ cwd: input.cwd, location: scope.location, iteration, change, - }); - const file = azureDevOpsFilePatch({ change, texts }); + }).pipe(Effect.orElseSucceed(() => null)); + const file = + texts === null + ? azureDevOpsUnreadableFilePatch(change) + : azureDevOpsFilePatch({ change, texts }); sections.push(file.section); bytes += file.section.length; truncated = truncated || file.truncated; @@ -425,8 +443,10 @@ export const make = Effect.gen(function* () { * it reports. One read covers every path: the latest iteration lists the whole change, so * asking per file would be the same answer fetched over and over. * - * A path the change no longer carries is left out rather than guessed at, which reads as the - * empty revision and leaves a file the pull request deletes cleared once and cleared for good. + * A path the change does not carry is at the empty revision, which is what a file the pull + * request deletes is at and leaves it cleared once and cleared for good. When the change was + * too long to follow to its end, those paths are left out instead: they were not looked at, + * and reporting them as deleted would clear a file nobody has read. */ getFileRevisions: (input) => Effect.gen(function* () { @@ -434,16 +454,21 @@ export const make = Effect.gen(function* () { if (input.paths.length === 0) return { revisions }; const scope = yield* diffScope(input); if (scope === null) return { revisions }; - const changes = yield* listLatestChanges({ + const listed = yield* listLatestChanges({ ...scope, cwd: input.cwd, number: input.number, }); const marked = new Set(input.paths); - for (const change of changes) { + for (const change of listed.changes) { if (!marked.has(change.path) || change.objectId === null) continue; revisions.set(change.path, change.objectId); } + if (!listed.truncated) { + for (const path of input.paths) { + if (!revisions.has(path)) revisions.set(path, ""); + } + } return { revisions }; }).pipe(Effect.mapError(fail("getFileRevisions"))), diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts index 1beba81e3660..585afdcb9d59 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { azureDevOpsFilePatch, + azureDevOpsUnreadableFilePatch, formatAzureDevOpsDiffCursor, parseAzureDevOpsDiffCursor, } from "./azureDevOpsDiff.ts"; @@ -18,11 +19,15 @@ function change(overrides: Partial = {}): AzureDevOpsCha }; } +function texts(oldContents: string, newContents: string, binary = false) { + return { oldContents, newContents, binary }; +} + describe("azureDevOpsFilePatch", () => { it("writes a changed file as the unified patch every diff viewer already reads", () => { const patch = azureDevOpsFilePatch({ change: change(), - texts: { oldContents: "one\ntwo\nthree\n", newContents: "one\ntwo again\nthree\n" }, + texts: texts("one\ntwo\nthree\n", "one\ntwo again\nthree\n"), }); expect(patch.truncated).toBe(false); @@ -44,7 +49,7 @@ describe("azureDevOpsFilePatch", () => { it("names the side a new file does not have as /dev/null", () => { const patch = azureDevOpsFilePatch({ change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }), - texts: { oldContents: "", newContents: "hello\n" }, + texts: texts("", "hello\n"), }); expect(patch.section).toContain("new file mode 100644"); @@ -58,7 +63,7 @@ describe("azureDevOpsFilePatch", () => { it("names the side a deleted file no longer has as /dev/null", () => { const patch = azureDevOpsFilePatch({ change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }), - texts: { oldContents: "gone\n", newContents: "" }, + texts: texts("gone\n", ""), }); expect(patch.section).toContain("deleted file mode 100644"); @@ -73,7 +78,7 @@ describe("azureDevOpsFilePatch", () => { // the reader to look at a change that is not the one on the host. const patch = azureDevOpsFilePatch({ change: change(), - texts: { oldContents: "one\r\ntwo\r\n", newContents: "one\r\ntwo again\r\n" }, + texts: texts("one\r\ntwo\r\n", "one\r\ntwo again\r\n"), }); expect(patch.section).toContain("-two\r"); @@ -83,7 +88,7 @@ describe("azureDevOpsFilePatch", () => { it("keeps a file that only moved, which has no hunks to give", () => { const patch = azureDevOpsFilePatch({ change: change({ path: "docs/new.md", oldPath: "docs/old.md", changeKind: "rename-pure" }), - texts: { oldContents: "same\n", newContents: "same\n" }, + texts: texts("same\n", "same\n"), }); expect(patch.truncated).toBe(false); @@ -102,7 +107,7 @@ describe("azureDevOpsFilePatch", () => { it("reports a binary file as changed rather than spelling it out", () => { const patch = azureDevOpsFilePatch({ change: change({ path: "logo.png", oldPath: "logo.png" }), - texts: { oldContents: "PNG\u0000old", newContents: "PNG\u0000new" }, + texts: texts("PNG\u0000old", "PNG\u0000new"), }); expect(patch.truncated).toBe(true); @@ -112,7 +117,7 @@ describe("azureDevOpsFilePatch", () => { it("shows an overlong file as changed without its hunks", () => { const patch = azureDevOpsFilePatch({ change: change({ path: "bundle.js", oldPath: "bundle.js" }), - texts: { oldContents: "a\n".repeat(400_000), newContents: "b\n".repeat(400_000) }, + texts: texts("a\n".repeat(400_000), "b\n".repeat(400_000)), }); expect(patch.truncated).toBe(true); @@ -121,16 +126,51 @@ describe("azureDevOpsFilePatch", () => { ); }); + it("takes the host's word that a file is binary, whatever its bytes look like", () => { + // Azure hands such a file over base64-encoded, so nothing in the text it sent gives it away. + const patch = azureDevOpsFilePatch({ + change: change({ path: "logo.png", oldPath: "logo.png" }), + texts: texts("b2xk", "bmV3", true), + }); + + expect(patch.truncated).toBe(true); + expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ"); + }); + + it("counts an overlong file in bytes rather than in characters", () => { + // Three bytes each, so a ceiling counted in code units would let three times the size through. + const patch = azureDevOpsFilePatch({ + change: change({ path: "notes.md", oldPath: "notes.md" }), + texts: texts("\u4e00".repeat(200_000), "\u4e8c".repeat(200_000)), + }); + + expect(patch.truncated).toBe(true); + expect(patch.section).toBe( + ["diff --git a/notes.md b/notes.md", "--- a/notes.md", "+++ b/notes.md", ""].join("\n"), + ); + }); + it("marks a file that does not end in a newline, as git does", () => { const patch = azureDevOpsFilePatch({ change: change(), - texts: { oldContents: "one\n", newContents: "two" }, + texts: texts("one\n", "two"), }); expect(patch.section).toContain("\\ No newline at end of file"); }); }); +describe("azureDevOpsUnreadableFilePatch", () => { + it("keeps a file the host would not hand over, listed without its hunks", () => { + const patch = azureDevOpsUnreadableFilePatch(change({ path: "huge.bin", oldPath: "huge.bin" })); + + expect(patch.truncated).toBe(true); + expect(patch.section).toBe( + ["diff --git a/huge.bin b/huge.bin", "--- a/huge.bin", "+++ b/huge.bin", ""].join("\n"), + ); + }); +}); + describe("a diff cursor", () => { it("carries the push it was taken against back to the next slice", () => { const cursor = formatAzureDevOpsDiffCursor({ iterationId: 3, fileIndex: 12 }); diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts index 97d811c8b35c..e8855580cfc0 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -37,6 +37,11 @@ export function parseAzureDevOpsDiffCursor( export interface AzureDevOpsFileTexts { readonly oldContents: string; readonly newContents: string; + /** + * The host's own word on whether this is a file it will not spell out. Azure hands such a file + * over base64-encoded, so its bytes are not in the text to be looked for. + */ + readonly binary: boolean; } export interface AzureDevOpsFilePatch { @@ -67,6 +72,13 @@ function isBinary(contents: string): boolean { return contents.includes("\u0000"); } +/** + * What a file costs on the wire, which is its bytes rather than its code units: a ceiling counted + * in characters lets a file of three-byte glyphs through at three times the size meant to be let + * through. + */ +const byteLength = (contents: string) => Buffer.byteLength(contents, "utf8"); + /** * Git points an empty range at the line before it, which is line zero for a file that is wholly * new or wholly gone, and writes a single line as its number alone. @@ -106,12 +118,12 @@ export function azureDevOpsFilePatch(input: { const header = patchHeader(input.change); const { oldContents, newContents } = input.texts; - if (isBinary(oldContents) || isBinary(newContents)) { + if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) { // Git's own wording for a file it will not spell out, which every diff viewer already reads. const binary = `Binary files a/${input.change.oldPath} and b/${input.change.path} differ`; return { section: `${header}\n${binary}\n`, truncated: true }; } - if (oldContents.length > MAX_FILE_BYTES || newContents.length > MAX_FILE_BYTES) { + if (byteLength(oldContents) > MAX_FILE_BYTES || byteLength(newContents) > MAX_FILE_BYTES) { return { section: `${header}\n`, truncated: true }; } @@ -137,3 +149,14 @@ export function azureDevOpsFilePatch(input: { truncated: false, }; } + +/** + * A file listed without its hunks, for when the host would not hand one of its two sides over. + * The change still belongs in the patch: leaving it out would take the file out of the review + * altogether, and the reader would have no sign anything was missing. + */ +export function azureDevOpsUnreadableFilePatch( + change: AzureDevOpsChangeEntry, +): AzureDevOpsFilePatch { + return { section: `${patchHeader(change)}\n`, truncated: true }; +} diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts index 6c94e88e9584..c0ce0406bc93 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts @@ -350,7 +350,7 @@ describe("decodeIterationsJson", () => { describe("decodeIterationChangesJson", () => { it("names each changed file without the slash Azure leads its paths with", () => { - const changes = expectSuccess( + const page = expectSuccess( decodeIterationChangesJson( asJson({ changeEntries: [ @@ -365,7 +365,7 @@ describe("decodeIterationChangesJson", () => { ), ); - expect(changes.map((change) => [change.path, change.changeKind])).toEqual([ + expect(page.changes.map((change) => [change.path, change.changeKind])).toEqual([ ["DEMO.md", "new"], ["README.md", "change"], ["OLD.md", "deleted"], @@ -373,7 +373,7 @@ describe("decodeIterationChangesJson", () => { }); it("reads a rename as one file that moved, and says whether it also changed", () => { - const changes = expectSuccess( + const page = expectSuccess( decodeIterationChangesJson( asJson({ changeEntries: [ @@ -392,7 +392,7 @@ describe("decodeIterationChangesJson", () => { ), ); - expect(changes).toEqual([ + expect(page.changes).toEqual([ { path: "docs/new.md", oldPath: "docs/old.md", @@ -412,7 +412,7 @@ describe("decodeIterationChangesJson", () => { it("drops the folders Azure lists alongside the files that changed", () => { // A review shows files, and a folder has no content on either side to show for one. - const changes = expectSuccess( + const page = expectSuccess( decodeIterationChangesJson( asJson({ changeEntries: [ @@ -423,7 +423,52 @@ describe("decodeIterationChangesJson", () => { ), ); - expect(changes.map((change) => change.path)).toEqual(["docs/page.md"]); + expect(page.changes.map((change) => change.path)).toEqual(["docs/page.md"]); + }); + + it("carries where the next page of a long change starts", () => { + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }], + nextSkip: 2000, + }), + ), + ); + + expect(page.nextSkip).toBe(2000); + }); + + it("reads the last page, which names no page after it, as the end of the change", () => { + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }], + }), + ), + ); + + expect(page.nextSkip).toBeNull(); + }); + + it("reads where a rename came from out of either of the two places Azure names it", () => { + // The iteration-changes route answers with `originalPath`; the commit routes answer with + // `sourceServerItem`, and both are the same fact under two names. + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { + changeType: "rename", + originalPath: "/docs/old.md", + item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, + }, + ], + }), + ), + ); + + expect(page.changes.at(0)?.oldPath).toBe("docs/old.md"); }); }); @@ -431,10 +476,24 @@ describe("decodeItemContentJson", () => { it("reads the file's text out of the envelope Azure wraps it in", () => { expect( expectSuccess(decodeItemContentJson(asJson({ path: "/a.md", content: "one\ntwo" }))), - ).toBe("one\ntwo"); + ).toEqual({ contents: "one\ntwo", isBinary: false }); }); it("reads an empty file as empty rather than as a failure to look", () => { - expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toBe(""); + expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toEqual({ + contents: "", + isBinary: false, + }); + }); + + it("keeps Azure's own word that a file is binary", () => { + // Which it answers base64-encoded, so nothing in the text it sent would give it away. + expect( + expectSuccess( + decodeItemContentJson( + asJson({ path: "/logo.png", content: "b2xk", contentMetadata: { isBinary: true } }), + ), + ), + ).toEqual({ contents: "b2xk", isBinary: true }); }); }); diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts index bb01e578b4a3..0c7db28a8a18 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts @@ -369,6 +369,8 @@ const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknow const RawChangeEntrySchema = Schema.Struct({ changeType: Schema.optional(Schema.NullOr(Schema.String)), sourceServerItem: Schema.optional(Schema.NullOr(Schema.String)), + /** Where a renamed file came from. Azure states it here on an iteration's changes. */ + originalPath: Schema.optional(Schema.NullOr(Schema.String)), item: Schema.optional( Schema.NullOr( Schema.Struct({ @@ -383,10 +385,18 @@ const RawChangeEntrySchema = Schema.Struct({ ), }); -const RawChangePageSchema = Schema.Struct({ changeEntries: Schema.Array(Schema.Unknown) }); +const RawChangePageSchema = Schema.Struct({ + changeEntries: Schema.Array(Schema.Unknown), + /** Where the page after this one starts. Azure leaves it out on the last page. */ + nextSkip: Schema.optional(Schema.NullOr(Schema.Number)), +}); const RawItemContentSchema = Schema.Struct({ content: Schema.optional(Schema.NullOr(Schema.String)), + /** What Azure makes of the file it is handing over, which is where it says it is not text. */ + contentMetadata: Schema.optional( + Schema.NullOr(Schema.Struct({ isBinary: Schema.optional(Schema.NullOr(Schema.Boolean)) })), + ), }); /** The head and the merge base of one iteration, which is the range its patch is taken over. */ @@ -408,6 +418,22 @@ export interface AzureDevOpsChangeEntry { readonly originalObjectId: string | null; } +/** + * One page of what an iteration changed, and where the next one starts. Azure pages this route + * rather than answering with the whole change, so a review large enough to be paged is followed + * to its end instead of being cut off at the first page's worth. + */ +export interface AzureDevOpsChangePage { + readonly changes: ReadonlyArray; + readonly nextSkip: number | null; +} + +/** One file's text at one commit, and whether Azure says the text is text at all. */ +export interface AzureDevOpsItemContent { + readonly contents: string; + readonly isBinary: boolean; +} + const decodeIterationPage = decodeJsonResult(RawIterationPageSchema); const decodeIterationEntry = Schema.decodeUnknownExit(RawIterationSchema); const decodeChangePage = decodeJsonResult(RawChangePageSchema); @@ -466,7 +492,7 @@ export function decodeIterationsJson( export function decodeIterationChangesJson( raw: string, -): Result.Result, DecodeFailure> { +): Result.Result { const decoded = decodeChangePage(raw); if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); const changes: AzureDevOpsChangeEntry[] = []; @@ -480,7 +506,10 @@ export function decodeIterationChangesJson( // files, and a folder has no content to show for either side of one. if (change.item?.isFolder === true) continue; if ((change.item?.gitObjectType ?? "blob").toLowerCase() !== "blob") continue; - const oldPath = toRepositoryPath(change.sourceServerItem) ?? path; + // Azure names where a renamed file came from in either of two places depending on the route + // and the version, so both are read and the current path stands in when neither is there. + const oldPath = + toRepositoryPath(change.sourceServerItem) ?? toRepositoryPath(change.originalPath) ?? path; changes.push({ path, oldPath, @@ -489,13 +518,28 @@ export function decodeIterationChangesJson( originalObjectId: trimmed(change.item?.originalObjectId), }); } - return Result.succeed(changes); + const nextSkip = decoded.success.nextSkip ?? null; + return Result.succeed({ + changes, + nextSkip: nextSkip !== null && Number.isSafeInteger(nextSkip) && nextSkip > 0 ? nextSkip : null, + }); } -/** Azure answers an absent file with an empty body rather than an error, which reads as empty. */ -export function decodeItemContentJson(raw: string): Result.Result { +/** + * Azure answers an absent file with an empty body rather than an error, which reads as empty. + * + * Whether the bytes are text is Azure's to say and not this decoder's to guess: a file it calls + * binary is reported as such however innocent its first bytes look, since Azure hands the body + * over in an encoding of its own choosing rather than verbatim. + */ +export function decodeItemContentJson( + raw: string, +): Result.Result { const decoded = decodeItemContent(raw); return Result.isSuccess(decoded) - ? Result.succeed(decoded.success.content ?? "") + ? Result.succeed({ + contents: decoded.success.content ?? "", + isBinary: decoded.success.contentMetadata?.isBinary === true, + }) : Result.fail(decoded.failure); } From 75c0bb8760516b3be26477757c85dfd6834eafbc Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 19:27:02 -0400 Subject: [PATCH 21/78] fix(server): a review's ticks survive what the host could not read A host that answers for part of a change said nothing about the rest, and that silence was read as deletion, so every file past the cut was cleared over a version nobody ever looked at. Two presses on one file could also finish in the other order, and two Azure repositories of the same name shared one row of ticks. Signed-off-by: Yordis Prieto --- .../BitbucketPullRequestApi.test.ts | 37 ++-- .../pullRequest/BitbucketPullRequestApi.ts | 44 ++--- .../pullRequest/GitLabPullRequestCli.test.ts | 11 +- .../src/pullRequest/GitLabPullRequestCli.ts | 11 +- .../src/pullRequest/PullRequestProvider.ts | 8 +- .../pullRequest/PullRequestService.test.ts | 169 +++++++++++++++++- .../src/pullRequest/PullRequestService.ts | 115 ++++++++++-- 7 files changed, 317 insertions(+), 78 deletions(-) diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index 7cdec6dc9a92..52ee64f4c6a9 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -395,36 +395,39 @@ layer("BitbucketPullRequestApi.layer", (it) => { paths: ["a.ts", "missing.ts"], }); - // `b.ts` is in the patch and was not asked about, and `missing.ts` was asked about and is - // not in the patch. Neither belongs in the answer. - assert.deepStrictEqual([...revisions], [["a.ts", "2222222"]]); + // `b.ts` is in the patch and was not asked about, so it does not belong in the answer. + // `missing.ts` was asked about and the whole patch was read without finding it, which is + // what a file this pull request deletes looks like, so it is answered as the empty version. + assert.deepStrictEqual( + [...revisions], + [ + ["a.ts", "2222222"], + ["missing.ts", ""], + ], + ); expect(callAt(0)).toMatchObject({ url: "/repositories/acme/web/pullrequests/71/diff" }); }), ); - it.effect("re-reads the patch once for a burst of presses rather than once per press", () => + it.effect("says nothing about the files past the end of a patch it could not read whole", () => Effect.gen(function* () { + // Bitbucket's patch is read up to a byte ceiling, and a file past the cut was not looked at. + // Answering for it as deleted would clear a mark on it once and for good. mockedRequest.mockReturnValueOnce( - Effect.succeed( - response("diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n"), - ), + Effect.succeed({ + body: "diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n", + truncated: true, + }), ); const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - const first = yield* api.getFileRevisions({ - repository: "acme/web", - number: 72, - paths: ["a.ts"], - }); - const second = yield* api.getFileRevisions({ + const revisions = yield* api.getFileRevisions({ repository: "acme/web", number: 72, - paths: ["a.ts"], + paths: ["a.ts", "past-the-cut.ts"], }); - assert.deepStrictEqual([...first], [["a.ts", "2222222"]]); - assert.deepStrictEqual([...second], [["a.ts", "2222222"]]); - assert.strictEqual(mockedRequest.mock.calls.length, 1); + assert.deepStrictEqual([...revisions], [["a.ts", "2222222"]]); }), ); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index b841210b6a6f..bd653bf58bb2 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -1,8 +1,5 @@ -import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; -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 Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -139,15 +136,6 @@ const CONVERSATION_PAGE_SIZE = 50; const CONVERSATION_PAGES = 10; /** The same ceiling the gh and glab diff reads use. */ const DIFF_MAX_BYTES = 8 * 1024 * 1024; -/** - * How long the versions read out of a patch stand for. The same window the diff itself is held - * for, deliberately: the patch on screen and what it is said to be at must not disagree, and a - * reader ticking their way down a file list would otherwise re-read the whole patch per press. - */ -const FILE_REVISIONS_CACHE_TTL = Duration.seconds(60); -/** Pull requests held at once, which is more than anyone has open. */ -const FILE_REVISIONS_CACHE_CAPACITY = 32; - export interface BitbucketPullRequestBatch { readonly items: ReadonlyArray; readonly truncated: boolean; @@ -197,9 +185,12 @@ export class BitbucketPullRequestApi extends Context.Service< /** * What the pull request's head has of each of these paths, as opaque ids. * - * Read off the pull request's own patch, the only place Bitbucket states a file's version, and - * held briefly so that ticking files off does not re-read it per press. Paths the patch says - * nothing about are left out. + * Read off the pull request's own patch, the only place Bitbucket states a file's version. A + * path the patch does not carry is answered as the empty revision, and left out altogether + * when the patch was cut short at the byte ceiling and so cannot be spoken for. + * + * Held by the caller rather than here: the marks and the badge they feed share one window, + * and a second one underneath it would keep answering after a refresh had asked it not to. */ readonly getFileRevisions: (input: { readonly repository: string; @@ -577,19 +568,6 @@ export const make = Effect.gen(function* () { ), ); - const fileRevisionsCache = yield* Cache.makeWith( - (key: string) => { - const [repository, number] = JSON.parse(key) as [string, number]; - return pullRequestDiff({ repository, number }).pipe( - Effect.map((diff) => parseDiffFileRevisions(diff.patch)), - ); - }, - { - capacity: FILE_REVISIONS_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? FILE_REVISIONS_CACHE_TTL : Duration.zero), - }, - ); - return BitbucketPullRequestApi.of({ getViewer: () => bitbucket.request({ method: "GET", url: "/user" }).pipe( @@ -666,14 +644,20 @@ export const make = Effect.gen(function* () { getFileRevisions: (input) => input.paths.length === 0 ? Effect.succeed(new Map()) - : Cache.get(fileRevisionsCache, JSON.stringify([input.repository, input.number])).pipe( - Effect.map((all) => { + : pullRequestDiff({ repository: input.repository, number: input.number }).pipe( + Effect.map((diff) => { + const all = parseDiffFileRevisions(diff.patch); // Narrowed to what was asked for rather than handed back whole: the caller compares // the paths it named, and a patch of a thousand files has no business in its answer. + // + // A patch cut short at the byte ceiling says nothing about the files past the cut, + // so those paths are left out rather than reported as removed: the caller reads an + // absent path as one it could not learn about, and a mark on it is left alone. const asked = new Map(); for (const path of input.paths) { const revision = all.get(path); if (revision !== undefined) asked.set(path, revision); + else if (!diff.truncated) asked.set(path, ""); } return asked; }), diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts index 64c1e6af5aa3..2460d2e1ccb5 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -1442,9 +1442,12 @@ layer("GitLabPullRequestCli.layer", (it) => { paths: ["src/a.ts", "src/gone.ts"], }); - // A path the head does not have is absent rather than empty, which is the answer for a - // file the merge request deletes. - expect([...revisions]).toEqual([["src/a.ts", "aaa"]]); + // Every path was looked for at the head, so one the head does not have is one the merge + // request removed: said as the empty version, which a mark on it was stamped with too. + expect([...revisions]).toEqual([ + ["src/a.ts", "aaa"], + ["src/gone.ts", ""], + ]); // The head the reader is looking at, not whatever the source branch has moved on to. // @effect-diagnostics-next-line preferSchemaOverJson:off const body: unknown = JSON.parse(callAt(1).stdin ?? "{}"); @@ -1491,13 +1494,11 @@ layer("GitLabPullRequestCli.layer", (it) => { ), ); mockedExecute.mockImplementation((request) => { - // @effect-diagnostics-next-line preferSchemaOverJson:off const body = JSON.parse(request.stdin ?? "{}") as { readonly variables: { readonly paths: ReadonlyArray }; }; return Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ data: { project: { diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index 17da291425fd..87fcd33482b6 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -1100,7 +1100,16 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ); }), - Effect.map((pages) => new Map(pages.flatMap((page) => [...page]))), + Effect.map((pages) => { + const revisions = new Map(pages.flatMap((page) => [...page])); + // Every path was looked for at the head, so one that is not there is one the merge + // request removed rather than one this could not read. Said as the empty revision, + // which is an answer the caller can compare against and keep. + for (const path of input.paths) { + if (!revisions.has(path)) revisions.set(path, ""); + } + return revisions as ReadonlyMap; + }), ); const viewerUsername = (input: { readonly cwd: string }) => diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index bb5d2e135e23..16f1e4c41ef8 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -212,8 +212,12 @@ export interface ProviderFilesViewed { * What version each of the asked-for files is at, on the change request's head. * * Opaque strings: the caller only ever compares one against another, and every host names a - * version its own way. A path the host answered nothing for is absent, which is the answer for a - * file the change request deletes rather than a failure to look. + * version its own way. The empty string is an answer rather than a gap — it is what a file the + * change request deletes is at, and a mark taken against it stays cleared. + * + * A path is absent only when the read could not say: a host that answered for part of the change + * must leave the rest out rather than report it as deleted, or a file past the cut would be + * cleared once and cleared for good. */ export interface ProviderFileRevisions { readonly revisions: ReadonlyMap; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 970b9142c1d1..13aaf25f8210 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,5 +1,7 @@ import { assert, it } from "@effect/vitest"; +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 TestClock from "effect/testing/TestClock"; import type { @@ -3484,6 +3486,7 @@ it.effect("keeps the diff cached across a file being ticked off", () => const environmentViewedProvider = ( revisions: Map, asked: Array>, + unreadable: ReadonlySet = new Set(), ) => fakeProvider("gitlab", { capabilities: { @@ -3502,11 +3505,12 @@ const environmentViewedProvider = ( getFileRevisions: (input) => { asked.push(input.paths); return Effect.succeed({ + // A path the host looked at and did not find is at the empty version, which is what a + // file the change request deletes is at. One it could not look at is left out entirely. revisions: new Map( - input.paths.flatMap((path) => { - const revision = revisions.get(path); - return revision === undefined ? [] : [[path, revision] as const]; - }), + input.paths.flatMap((path) => + unreadable.has(path) ? [] : [[path, revisions.get(path) ?? ""] as const], + ), ), }); }, @@ -3515,6 +3519,7 @@ const environmentViewedProvider = ( const environmentViewedService = ( revisions: Map, asked: Array>, + unreadable: ReadonlySet = new Set(), ) => makeService({ projects: [ @@ -3526,7 +3531,7 @@ const environmentViewedService = ( provider: "gitlab", }), ], - providers: [environmentViewedProvider(revisions, asked)], + providers: [environmentViewedProvider(revisions, asked, unreadable)], }); const GITLAB_REFERENCE = { @@ -3723,6 +3728,160 @@ it.effect("keeps a deleted file cleared, which the head has no version of at all }), ); +it.effect("leaves a mark alone when the host could not say what the head has of it", () => + Effect.gen(function* () { + // A host answers for as much of a long change as it can read in one go. Reading the rest as + // deleted would clear every file past the cut over a version nobody ever looked at. + const revisions = new Map([ + ["src/a.ts", "blob-a"], + ["src/past-the-cut.ts", "blob-b"], + ]); + const service = yield* environmentViewedService( + revisions, + [], + new Set(["src/past-the-cut.ts"]), + ); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [ + { path: "src/a.ts", viewed: true }, + { path: "src/past-the-cut.ts", viewed: true }, + ], + }); + revisions.set("src/a.ts", "blob-a-again"); + yield* service.invalidate({ reference: GITLAB_REFERENCE }); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual( + [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { path: "src/a.ts", state: "dismissed" }, + { path: "src/past-the-cut.ts", state: "viewed" }, + ], + ); + }), +); + +it.effect("finishes two presses on one file in the order they were made", () => + Effect.gen(function* () { + // A tick asks the host what it has of the file before it stores anything, and an untick asks + // nothing at all, so the second press would otherwise land first and be overwritten by the + // first one finishing behind it. + const held = yield* Deferred.make(); + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("gitlab", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + viewedFiles: "environment", + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getFilesViewed: () => Effect.die("the host keeps no marks of its own"), + setFilesViewed: () => Effect.die("the host keeps no marks of its own"), + getFileRevisions: (input) => + Deferred.await(held).pipe( + Effect.as({ revisions: new Map(input.paths.map((path) => [path, "blob-a"])) }), + ), + }), + ], + }); + + const tick = service + .setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }) + .pipe(Effect.runFork); + // Far enough for the tick to be waiting on the host rather than still on its way there. + yield* TestClock.adjust("1 second"); + const untick = service + .setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: false }], + }) + .pipe(Effect.runFork); + yield* TestClock.adjust("1 second"); + yield* Deferred.succeed(held, undefined); + yield* Fiber.join(tick); + yield* Fiber.join(untick); + + // The untick came second and stands: the file is open again. + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + assert.deepStrictEqual(marked.files, []); + }), +); + +it.effect("keeps the marks of two Azure repositories of the same name apart", () => + Effect.gen(function* () { + // Azure addresses a repository by its bare name, which is unique inside one of its projects + // and not across an organisation. Two `web` repositories would otherwise share one row. + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "platform web", + workspaceRoot: "/a", + repository: "acme/platform/_git/web", + provider: "azure-devops", + host: "dev.azure.com", + }), + project({ + id: "p2", + title: "other web", + workspaceRoot: "/b", + repository: "acme/other/_git/web", + provider: "azure-devops", + host: "dev.azure.com", + }), + ], + providers: [ + fakeProvider("azure-devops", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + viewedFiles: "environment", + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getFilesViewed: () => Effect.die("the host keeps no marks of this environment's own"), + setFilesViewed: () => Effect.die("the host keeps no marks of this environment's own"), + getFileRevisions: (input) => + Effect.succeed({ revisions: new Map(input.paths.map((path) => [path, "blob-a"])) }), + }), + ], + }); + const platform = { projectId: "p1" as ProjectId, repository: "web", number: 1 }; + const other = { projectId: "p2" as ProjectId, repository: "web", number: 1 }; + + yield* service.setFilesViewed({ ...platform, files: [{ path: "src/a.ts", viewed: true }] }); + + assert.deepStrictEqual((yield* service.filesViewed(platform)).files, [ + { path: "src/a.ts", state: "viewed" }, + ]); + assert.deepStrictEqual((yield* service.filesViewed(other)).files, []); + }), +); + it.effect("keeps environment marks apart from another change request's", () => Effect.gen(function* () { const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), []); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index e33988555361..a1cf959604f3 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -6,6 +6,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 Semaphore from "effect/Semaphore"; import { PullRequestOperationError, PullRequestUnavailableError, @@ -1315,6 +1316,20 @@ export const make = Effect.gen(function* () { /** Runs a refresh as its own fiber, for the reads that answer from a held value first. */ const runFork = Effect.runForkWith(context); + /** + * Which repository a row belongs to, spelled widely enough that two of them are two rows. + * + * A provider's own selector is what the reads are addressed by, and Azure's is the bare + * repository name: unique inside one project and not across an organisation, so `api` in two + * projects would otherwise share one row and show each other's ticks. The remote already + * carries the whole path, so the marks are keyed by that instead. + */ + const filesViewedRepositoryOf = (project: SupportedProject) => { + if (project.api.kind !== "azure-devops") return project.repository; + const path = project.project.repositoryIdentity?.displayName?.trim(); + return path === undefined || path.length === 0 ? project.repository : path; + }; + /** * Which change request's marks, and whose. The host is part of it because the same * `owner/repo` exists on more than one install, and the reader is part of it for the reason @@ -1324,7 +1339,7 @@ export const make = Effect.gen(function* () { const filesViewedScope = (project: SupportedProject, number: number, viewer: string | null) => ({ provider: project.api.kind, host: project.host, - repository: project.repository, + repository: filesViewedRepositoryOf(project), number, viewer: viewer ?? "", }); @@ -1349,6 +1364,14 @@ export const make = Effect.gen(function* () { } const heldFileRevisions = new Map(); const refreshingFileRevisions = new Set(); + /** + * Moved every time a held answer is dropped. A refresh already in flight when that happens + * still answers its own caller, and its answer is simply not kept: it was taken against a head + * the reader has since asked to stop believing, and keeping it would put the dropped entry + * straight back. One counter for every scope rather than one each, so an unrelated refresh + * costs an in-flight read its place in the cache and nothing else. + */ + let fileRevisionsGeneration = 0; /** * Normalised, because a reference reaches here spelled however the client spelled it while the * project carries the remote's own spelling, and a refresh that missed by a capital would leave @@ -1361,8 +1384,12 @@ export const make = Effect.gen(function* () { scope: string, paths: ReadonlyArray, answer: ReadonlyMap, + generation: number, ) => Effect.map(Clock.currentTimeMillis, (at) => { + // Answered from, never stored: the caller asked for this and it is as fresh as anything + // could be, but the scope it belongs to has been dropped since the read began. + if (generation !== fileRevisionsGeneration) return answer; const held = heldFileRevisions.get(scope); // Past the stale window the old entry is not worth merging into: it would carry paths // nobody has asked about since, at revisions the head has long moved off. @@ -1397,6 +1424,12 @@ export const make = Effect.gen(function* () { const forgetFileRevisions = (scope: string) => { heldFileRevisions.delete(scope); + fileRevisionsGeneration += 1; + }; + + const forgetEveryFileRevision = () => { + heldFileRevisions.clear(); + fileRevisionsGeneration += 1; }; /** @@ -1421,8 +1454,9 @@ export const make = Effect.gen(function* () { const scope = fileRevisionsScope(project.project.id, project.repository, number); // Suspended, so a held answer costs the host nothing: a provider is free to do its work as // the request is built rather than as the effect is run. - const fetch = Effect.suspend(() => - read({ + const fetch = Effect.suspend(() => { + const generation = fileRevisionsGeneration; + return read({ cwd: project.project.workspaceRoot, repository: project.repository, host: project.host, @@ -1430,9 +1464,9 @@ export const make = Effect.gen(function* () { paths, }).pipe( Effect.mapError(toPullRequestError(operation)), - Effect.flatMap((answer) => recordFileRevisions(scope, paths, answer.revisions)), - ), - ); + Effect.flatMap((answer) => recordFileRevisions(scope, paths, answer.revisions, generation)), + ); + }); return Effect.flatMap(Clock.currentTimeMillis, (now) => { const held = heldFileRevisionsFor(scope, paths, now); if (held === null) return fetch; @@ -1479,21 +1513,62 @@ export const make = Effect.gen(function* () { "filesViewed", ); return { - files: marks.map((mark) => ({ - path: mark.path, - // Absent reads as the empty revision on both sides, so a file the change request - // deletes is cleared once and stays cleared rather than reporting itself changed the - // moment it is ticked. - state: - revisions === null || (revisions.get(mark.path) ?? "") === mark.revision - ? ("viewed" as const) - : ("dismissed" as const), - })), + files: marks.map((mark) => { + // A path the host had no answer for is one it could not look at, not one it looked at + // and found nothing: a read that saw part of a large change must not report the rest + // as changed against a revision nobody read. A file the change request deletes is + // answered as the empty revision, which is what its mark was stamped with, so it is + // cleared once and stays cleared. + const revision = revisions?.get(mark.path); + return { + path: mark.path, + state: + revision === undefined || revision === mark.revision + ? ("viewed" as const) + : ("dismissed" as const), + }; + }), // Every mark is a row this environment holds, so there is no page to run out of. truncated: false, }; }); + /** + * One environment-backed write at a time per change request. A tick asks the host what it has + * of the file before it stores anything and an untick asks nothing at all, so two presses in + * quick succession would otherwise finish in the other order and leave the tick's row standing + * over the untick that came after it. + */ + const filesViewedGates = new Map< + string, + { readonly gate: Semaphore.Semaphore; pending: number } + >(); + + const inFilesViewedOrder = ( + project: SupportedProject, + number: number, + write: Effect.Effect, + ) => + Effect.gen(function* () { + const key = `${project.project.id} ${filesViewedRepositoryOf(project).trim().toLowerCase()} ${number}`; + const held = filesViewedGates.get(key); + const entry = held ?? { gate: yield* Semaphore.make(1), pending: 0 }; + if (held === undefined) filesViewedGates.set(key, entry); + entry.pending += 1; + // Dropped once nobody is queued behind it, so a long-lived server does not keep a gate per + // change request anyone has ever ticked a file in. + return yield* entry.gate + .withPermits(1)(write) + .pipe( + Effect.ensuring( + Effect.sync(() => { + entry.pending -= 1; + if (entry.pending === 0) filesViewedGates.delete(key); + }), + ), + ); + }); + const environmentSetFilesViewed = ( project: SupportedProject, input: PullRequestSetFilesViewedInput, @@ -1559,7 +1634,11 @@ export const make = Effect.gen(function* () { }).pipe(Effect.mapError(toPullRequestError("setFilesViewed"))); } if (project.api.capabilities.viewedFiles === "environment") { - return environmentSetFilesViewed(project, input); + return inFilesViewedOrder( + project, + input.number, + environmentSetFilesViewed(project, input), + ); } return Effect.fail( new PullRequestOperationError({ @@ -2380,7 +2459,7 @@ export const make = Effect.gen(function* () { // 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(); - heldFileRevisions.clear(); + forgetEveryFileRevision(); return; } bumpRefEpoch(input.reference); From a95bf642407bec835a47209129966f81cfa7e2e3 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 19:40:41 -0400 Subject: [PATCH 22/78] fix(server): a part-read Azure change no longer passes as the whole of it A page Azure names but the read stops at was reported as the end of the change, so files it never listed read as removed from the pull request. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestCli.test.ts | 138 +++++++++++++++++- .../pullRequest/AzureDevOpsPullRequestCli.ts | 23 ++- .../server/src/pullRequest/azureDevOpsDiff.ts | 14 +- 3 files changed, 165 insertions(+), 10 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index e8a5472c45a2..16bea79972ac 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -748,6 +748,140 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect("stops following pages by what Azure counts, not by what survives decoding", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + // Nothing a review can show, so every page decodes to nothing at all and a ceiling counted + // in files would never be reached however long the walk went on. + .mockImplementation((command) => { + const skip = command.args.find((arg) => arg.startsWith("$skip=")); + const from = Number(skip?.slice("$skip=".length) ?? 0); + return Effect.succeed( + output( + json({ + changeEntries: [{ changeType: "add", item: { path: "/src", isFolder: true } }], + nextSkip: from + 2_000, + }), + ), + ); + }); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.getFileRevisions); + + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["src/page.ts"], + }); + + // The pull request, its pushes, and five pages: the walk gives up on Azure's own offset + // rather than spending an `az` process a page for as long as Azure keeps paging. + assert.strictEqual(mockedExecute.mock.calls.length, 7); + // And it read part of a change, so it says nothing about the file it never saw. + assert.strictEqual(answer.revisions.size, 0); + }), + ); + + it.effect("takes Azure's own word on a file it will not spell out", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + changeEntries: [ + { changeType: "add", item: { path: "/logo.png", objectId: "8f80" } }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output(json({ content: "iVBORw0KGgo=", contentMetadata: { isBinary: true } })), + ), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + const slice = yield* provider.getDiff({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + }); + + // Azure leaves the metadata out unless it is asked for, and without it every file reads as + // text however it was stored. + expect(argsOfCall(3)).toContain("includeContentMetadata=true"); + expect(slice.patch).toContain("Binary files a/logo.png and b/logo.png differ"); + assert.isTrue(slice.truncated); + }), + ); + it.effect("stops following pages when one of them does not move the cursor on", () => Effect.gen(function* () { mockedExecute @@ -810,11 +944,13 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { repository: "web", host: "dev.azure.com", number: 42, - paths: ["a.ts", "b.ts"], + paths: ["a.ts", "b.ts", "unlisted.ts"], }); // Four reads and no more: a page pointing at where it already is would be read forever. assert.strictEqual(mockedExecute.mock.calls.length, 4); + // And what was read is not the whole change, so the file nobody listed is left unanswered + // rather than reported as gone from the change request. expect([...answer.revisions]).toEqual([ ["a.ts", "8f80"], ["b.ts", "0ca4"], diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 7d6089c5a145..9f56b965a4a9 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -123,10 +123,14 @@ const REST_API_VERSION = "7.1"; const CHANGE_ENTRIES_PER_PAGE = 2000; /** - * Where following the pages stops. Every page is an `az` process of its own, and a change this + * Where following the pages stops, counted in the entries Azure was asked to skip rather than in + * the files that survived decoding. Every page is an `az` process of its own, and a change this * long is past what any reader will get through, so the read gives up rather than spending a * minute of spawns on it. Saying so is the point: the diff reports itself as incomplete instead * of presenting five pages as the whole change. + * + * Azure's own count is what bounds this, because a page can be entirely folders and other entries + * a review has nothing to show for. Bounding on what was kept would follow such a change forever. */ const MAX_CHANGE_ENTRIES = 10_000; @@ -585,13 +589,12 @@ export const make = Effect.gen(function* () { page(skip).pipe( Effect.flatMap((answer) => { const changes = [...collected, ...answer.changes]; - // A page that does not move the cursor on would be read forever, and a change this - // long is past anything a reader will get through — so the read stops and says so, - // rather than quietly presenting part of it as the whole. - if (answer.nextSkip === null || answer.nextSkip <= skip) { - return Effect.succeed({ changes, truncated: false }); - } - return changes.length >= MAX_CHANGE_ENTRIES + // The last page names no page after it, and only that is the end of the change. + if (answer.nextSkip === null) return Effect.succeed({ changes, truncated: false }); + // A page pointing at where the read already is would be followed forever, and one + // past the ceiling is a change nobody will read to the end of. Both stop the read + // and both say so, rather than presenting part of a change as the whole of it. + return answer.nextSkip <= skip || answer.nextSkip >= MAX_CHANGE_ENTRIES ? Effect.succeed({ changes, truncated: true }) : from(answer.nextSkip, changes); }), @@ -610,6 +613,10 @@ export const make = Effect.gen(function* () { "versionDescriptor.versionType=commit", `versionDescriptor.version=${input.commit}`, "includeContent=true", + // Azure leaves `contentMetadata` out unless this is asked for, and with it goes its own + // word on whether the file is binary — which is the only reliable one, since a binary + // file arrives encoded rather than as the bytes it is on the host. + "includeContentMetadata=true", // Without this Azure answers with the file's own bytes rather than with a JSON // envelope, and `az devops invoke` refuses anything it cannot parse as JSON. "$format=json", diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts index e8855580cfc0..39d7e6e52345 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -60,6 +60,14 @@ const MAX_FILE_BYTES = 512 * 1024; /** Git's own default, and what the hunks from this repo's other hosts are already cut to. */ const PATCH_CONTEXT_LINES = 3; +/** + * How long one file may be diffed for. The line diff costs the product of the two sides, so a pair + * of files under the size ceiling that share almost nothing can still hold the whole server for a + * long time. Past this the file is listed without its hunks, which is what the size ceiling already + * does and what the reader is already shown a sign of. + */ +const MAX_FILE_DIFF_MILLIS = 2_000; + /** * How much patch one slice carries before the rest is left for the next one. Every file costs a * request per side, so the read stops on what it has produced rather than on a file count: a @@ -134,8 +142,12 @@ export function azureDevOpsFilePatch(input: { newContents, undefined, undefined, - { context: PATCH_CONTEXT_LINES }, + { context: PATCH_CONTEXT_LINES, timeout: MAX_FILE_DIFF_MILLIS }, ); + // The bound is reported by giving nothing back, and a file whose diff was given up on is a file + // listed without its hunks rather than a file dropped from the change. + if (patch === undefined) return { section: `${header}\n`, truncated: true }; + const hunks = patch.hunks.map((hunk) => [ `@@ -${hunkRange(hunk.oldStart, hunk.oldLines)} +${hunkRange(hunk.newStart, hunk.newLines)} @@`, From 644839c9cf5391c0116cdc290c8a06509dfa309e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 19:53:47 -0400 Subject: [PATCH 23/78] fix(server): a diff given up on no longer costs the whole slice again per file One file's diff was bounded but a slice of them was not, and the page opening a review passed the URL's spelling of a repository where the panel passed the server's. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestProvider.ts | 9 +++-- .../src/pullRequest/azureDevOpsDiff.test.ts | 35 +++++++++++++++++++ .../server/src/pullRequest/azureDevOpsDiff.ts | 24 +++++++++---- apps/web/src/lib/openPullRequestLink.ts | 12 ++++--- 4 files changed, 67 insertions(+), 13 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 034df758f1ba..fcf059b0f183 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -8,6 +8,7 @@ import { formatAzureDevOpsDiffCursor, parseAzureDevOpsDiffCursor, MAX_DIFF_SLICE_BYTES, + MAX_FILE_DIFF_MILLIS, type AzureDevOpsFileTexts, } from "./azureDevOpsDiff.ts"; import { @@ -400,12 +401,16 @@ export const make = Effect.gen(function* () { const file = texts === null ? azureDevOpsUnreadableFilePatch(change) - : azureDevOpsFilePatch({ change, texts }); + : azureDevOpsFilePatch({ change, texts, timeoutMillis: MAX_FILE_DIFF_MILLIS }); sections.push(file.section); bytes += file.section.length; truncated = truncated || file.truncated; index += 1; - if (bytes >= MAX_DIFF_SLICE_BYTES) break; + // A file whose diff was given up on spent the whole of what one file is allowed and has + // a header to show for it, so the byte budget would let a change full of them spend that + // over and over in the one request. The slice ends there instead, and reading on picks + // up at the file behind it. + if (bytes >= MAX_DIFF_SLICE_BYTES || file.abandoned) break; } const slice: ProviderDiffSlice = { diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts index 585afdcb9d59..63b790b0a068 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts @@ -150,6 +150,41 @@ describe("azureDevOpsFilePatch", () => { ); }); + it("gives up on a file whose two sides are too far apart to diff in the time allowed", () => { + // The line diff costs the product of the two sides, so a pair under the size ceiling that + // shares nothing still runs long. Left to itself it would hold the server for as long as it + // took; here it is given a millisecond so the giving up is the thing being read. + const oldContents = Array.from({ length: 3_000 }, (_, line) => `old ${line}`).join("\n"); + const newContents = Array.from({ length: 3_000 }, (_, line) => `new ${line}`).join("\n"); + const patch = azureDevOpsFilePatch({ + change: change({ path: "generated.ts", oldPath: "generated.ts" }), + texts: texts(oldContents, newContents), + timeoutMillis: 1, + }); + + expect(patch.truncated).toBe(true); + // And it says so, because the reader of a run of files is meant to stop rather than spend + // that time again on each of the ones behind it. + expect(patch.abandoned).toBe(true); + expect(patch.section).toBe( + [ + "diff --git a/generated.ts b/generated.ts", + "--- a/generated.ts", + "+++ b/generated.ts", + "", + ].join("\n"), + ); + }); + + it("keeps a file it did diff out of the giving up", () => { + const patch = azureDevOpsFilePatch({ + change: change(), + texts: texts("one\ntwo\n", "one\ntwo again\n"), + }); + + expect(patch.abandoned).toBe(false); + }); + it("marks a file that does not end in a newline, as git does", () => { const patch = azureDevOpsFilePatch({ change: change(), diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts index 39d7e6e52345..1ea63ac41edf 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -48,6 +48,12 @@ export interface AzureDevOpsFilePatch { readonly section: string; /** The file changed but its hunks are not in the section, so the patch has a hole in it. */ readonly truncated: boolean; + /** + * The diff was given up on partway rather than declined on sight, so this file spent the whole + * of what one file is allowed and produced a header for it. The caller reading a run of files + * is meant to stop here rather than pay that again for each of the ones behind it. + */ + readonly abandoned: boolean; } /** @@ -66,7 +72,7 @@ const PATCH_CONTEXT_LINES = 3; * long time. Past this the file is listed without its hunks, which is what the size ceiling already * does and what the reader is already shown a sign of. */ -const MAX_FILE_DIFF_MILLIS = 2_000; +export const MAX_FILE_DIFF_MILLIS = 2_000; /** * How much patch one slice carries before the rest is left for the next one. Every file costs a @@ -122,6 +128,8 @@ function patchHeader(change: AzureDevOpsChangeEntry): string { export function azureDevOpsFilePatch(input: { readonly change: AzureDevOpsChangeEntry; readonly texts: AzureDevOpsFileTexts; + /** How long this one file may be diffed for, at most what any file is allowed. */ + readonly timeoutMillis?: number; }): AzureDevOpsFilePatch { const header = patchHeader(input.change); const { oldContents, newContents } = input.texts; @@ -129,10 +137,10 @@ export function azureDevOpsFilePatch(input: { if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) { // Git's own wording for a file it will not spell out, which every diff viewer already reads. const binary = `Binary files a/${input.change.oldPath} and b/${input.change.path} differ`; - return { section: `${header}\n${binary}\n`, truncated: true }; + return { section: `${header}\n${binary}\n`, truncated: true, abandoned: false }; } if (byteLength(oldContents) > MAX_FILE_BYTES || byteLength(newContents) > MAX_FILE_BYTES) { - return { section: `${header}\n`, truncated: true }; + return { section: `${header}\n`, truncated: true, abandoned: false }; } const patch = structuredPatch( @@ -142,11 +150,14 @@ export function azureDevOpsFilePatch(input: { newContents, undefined, undefined, - { context: PATCH_CONTEXT_LINES, timeout: MAX_FILE_DIFF_MILLIS }, + { + context: PATCH_CONTEXT_LINES, + timeout: Math.min(input.timeoutMillis ?? MAX_FILE_DIFF_MILLIS, MAX_FILE_DIFF_MILLIS), + }, ); // The bound is reported by giving nothing back, and a file whose diff was given up on is a file // listed without its hunks rather than a file dropped from the change. - if (patch === undefined) return { section: `${header}\n`, truncated: true }; + if (patch === undefined) return { section: `${header}\n`, truncated: true, abandoned: true }; const hunks = patch.hunks.map((hunk) => [ @@ -159,6 +170,7 @@ export function azureDevOpsFilePatch(input: { return { section: hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`, truncated: false, + abandoned: false, }; } @@ -170,5 +182,5 @@ export function azureDevOpsFilePatch(input: { export function azureDevOpsUnreadableFilePatch( change: AzureDevOpsChangeEntry, ): AzureDevOpsFilePatch { - return { section: `${patchHeader(change)}\n`, truncated: true }; + return { section: `${patchHeader(change)}\n`, truncated: true, abandoned: false }; } diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 888e3e8339d5..ceff696f2157 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -263,13 +263,15 @@ export function useOpenChangeRequestLink( if (project === undefined || !reads(project.environmentId)) return false; event.preventDefault(); event.stopPropagation(); + // The selector the server derives from the same identity, not the one read out of the URL: + // a ref spelled any other way is refused before it reaches a provider, and matching a link + // only ever compares lower case. The page reads it back the same way the panel does, so + // both surfaces are handed the same spelling. + const repository = pullRequestRepositoryOf(project.repositoryIdentity) ?? parsed.repository; if (resolvedThreadRef) { useRightPanelStore.getState().openPullRequest(resolvedThreadRef, { projectId: project.id, - // The selector the server derives from the same identity, not the one read out of the - // URL: a ref spelled any other way is refused before it reaches a provider, and - // matching a link only ever compares lower case. - repository: pullRequestRepositoryOf(project.repositoryIdentity) ?? parsed.repository, + repository, number: parsed.number, }); return true; @@ -281,7 +283,7 @@ export function useOpenChangeRequestLink( // Every state, so the pull request being opened is also in the list behind it whether // it is open, merged or closed. state: "all", - repository: parsed.repository, + repository, number: parsed.number, selectedProjectId: project.id, // Named so the page opens the right one of two servers holding this project. From b060805da72b4e7fcda2a390cea3c603ccd7c23c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 20:03:43 -0400 Subject: [PATCH 24/78] fix(server): a rate-limited Azure diff no longer reads as a change with no hunks A failed side read was degraded to a file listed without its hunks whatever the reason, so a signed-out or throttled host produced a whole change of empty files instead of a failure the app pauses on. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestCli.test.ts | 106 ++++++++++++++++++ .../AzureDevOpsPullRequestProvider.ts | 19 +++- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index 16bea79972ac..f02726e6d887 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -32,6 +32,27 @@ function output(stdout: string) { /** A fixture's own shape, spelled the way `az` would answer with it. */ const json = (value: Record) => JSON.stringify(value); +const pullRequestRow = { + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, +}; + +const oneIteration = { + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], +}; + function pullRequestRows( count: number, firstNumber: number, @@ -815,6 +836,91 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect("leaves one file the host would not hand over listed without its hunks", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))) + .mockReturnValueOnce(Effect.succeed(output(json(oneIteration)))) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + changeEntries: [ + { changeType: "add", item: { path: "/huge.bin", objectId: "8f80" } }, + { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.fail( + new AzureDevOpsCli.AzureDevOpsCommandFailedError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 1, + cause: "the blob is past what the route will carry", + }), + ), + ) + .mockReturnValueOnce(Effect.succeed(output(json({ content: "hello\n" })))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + const slice = yield* provider.getDiff({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + }); + + assert.isTrue(slice.truncated); + expect(slice.patch).toContain("diff --git a/huge.bin b/huge.bin"); + // And the file behind it still renders, which is the point of giving up on one file. + expect(slice.patch).toContain("+hello"); + }), + ); + + it.effect("fails the whole read when it is the connection that would not answer", () => + Effect.gen(function* () { + // A rate limit is not this file's problem, and answering with a change full of files listed + // without their hunks would read as a change nobody can see rather than as a host to wait + // for. + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))) + .mockReturnValueOnce(Effect.succeed(output(json(oneIteration)))) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + changeEntries: [ + { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.fail( + new AzureDevOpsCli.AzureDevOpsCliRateLimitError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 1, + cause: "429", + }), + ), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + const error = yield* Effect.flip( + provider.getDiff({ cwd: "/w", repository: "web", host: "dev.azure.com", number: 42 }), + ); + + assert.strictEqual(error.reason, "rate-limited"); + }), + ); + it.effect("takes Azure's own word on a file it will not spell out", () => Effect.gen(function* () { mockedExecute diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index fcf059b0f183..56c8e7460939 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -20,7 +20,10 @@ import { type ProviderDiffSlice, type PullRequestProviderApi, } from "./PullRequestProvider.ts"; -import type { AzureDevOpsIterationChanges } from "./AzureDevOpsPullRequestCli.ts"; +import type { + AzureDevOpsIterationChanges, + AzureDevOpsPullRequestCliError, +} from "./AzureDevOpsPullRequestCli.ts"; import type { AzureDevOpsChangeEntry, AzureDevOpsItemContent, @@ -196,6 +199,18 @@ export const make = Effect.gen(function* () { const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false }; + /** + * Whether a failed side read is this one file's problem rather than the whole connection's. A + * path `az` will not carry, a blob it will not hand over and an answer that came back unreadable + * are all one file, and the rest of the change still renders around it. A signed-out CLI, a rate + * limit or no `az` at all is the read failing, and belongs to the caller, which pauses the host + * rather than showing every file in the change as unreadable. + */ + const isFileScopedReadFailure = (error: AzureDevOpsPullRequestCliError): boolean => + error._tag === "AzureDevOpsPullRequestNotFoundError" || + error._tag === "AzureDevOpsCommandFailedError" || + error._tag === "AzureDevOpsPullRequestReadError"; + /** * Both sides of one changed file. Only the sides a change actually has are asked for: Azure * answers for a file that is not at a commit with a failure rather than with nothing. @@ -397,7 +412,7 @@ export const make = Effect.gen(function* () { location: scope.location, iteration, change, - }).pipe(Effect.orElseSucceed(() => null)); + }).pipe(Effect.catchIf(isFileScopedReadFailure, () => Effect.succeed(null))); const file = texts === null ? azureDevOpsUnreadableFilePatch(change) From 6e9d61c3cefc1b60c17054f2efa14f13f3796420 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 20:09:42 -0400 Subject: [PATCH 25/78] fix(web): the viewed count no longer pushes the code toolbar off the strip The count was spelled out at a fixed width beside controls that cannot give way, and the Azure per-file recovery named its failures through a predicate where the tags say it plainly. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestProvider.ts | 28 ++++++++----------- .../pullRequest/PullRequestCodeTab.tsx | 14 +++++++--- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 56c8e7460939..21d477b5a069 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -20,10 +20,7 @@ import { type ProviderDiffSlice, type PullRequestProviderApi, } from "./PullRequestProvider.ts"; -import type { - AzureDevOpsIterationChanges, - AzureDevOpsPullRequestCliError, -} from "./AzureDevOpsPullRequestCli.ts"; +import type { AzureDevOpsIterationChanges } from "./AzureDevOpsPullRequestCli.ts"; import type { AzureDevOpsChangeEntry, AzureDevOpsItemContent, @@ -199,18 +196,6 @@ export const make = Effect.gen(function* () { const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false }; - /** - * Whether a failed side read is this one file's problem rather than the whole connection's. A - * path `az` will not carry, a blob it will not hand over and an answer that came back unreadable - * are all one file, and the rest of the change still renders around it. A signed-out CLI, a rate - * limit or no `az` at all is the read failing, and belongs to the caller, which pauses the host - * rather than showing every file in the change as unreadable. - */ - const isFileScopedReadFailure = (error: AzureDevOpsPullRequestCliError): boolean => - error._tag === "AzureDevOpsPullRequestNotFoundError" || - error._tag === "AzureDevOpsCommandFailedError" || - error._tag === "AzureDevOpsPullRequestReadError"; - /** * Both sides of one changed file. Only the sides a change actually has are asked for: Azure * answers for a file that is not at a commit with a failure rather than with nothing. @@ -412,7 +397,16 @@ export const make = Effect.gen(function* () { location: scope.location, iteration, change, - }).pipe(Effect.catchIf(isFileScopedReadFailure, () => Effect.succeed(null))); + }).pipe( + // Only what is this one file's problem. A signed-out CLI, a rate limit or no `az` at + // all is the read failing rather than the file, and belongs to the caller, which + // pauses the host rather than showing every file in the change as unreadable. + Effect.catchTags({ + AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null), + AzureDevOpsCommandFailedError: () => Effect.succeed(null), + AzureDevOpsPullRequestReadError: () => Effect.succeed(null), + }), + ); const file = texts === null ? azureDevOpsUnreadableFilePatch(change) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 9fce1ed1647e..6bc64e541f9f 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -1147,11 +1147,17 @@ export function PullRequestCodeTab({ {nextCursor === null ? "" : "+"} {filesViewed.enabled && files.length > 0 ? ( - + {/* Named on a host that keeps no record of its own, so the reader is told whose - ticks these are without having to find the icon beside them. */} - {filesViewed.viewedCount} / {files.length}{" "} - {viewedFilesStore === "environment" ? `viewed in ${APP_BASE_NAME}` : "viewed"} + ticks these are without having to find the icon beside them. The count holds its + width and the wording gives way, so this segment cannot push the controls on the + right off the strip in the narrow right panel. */} + + {filesViewed.viewedCount} / {files.length} + + + {viewedFilesStore === "environment" ? `viewed in ${APP_BASE_NAME}` : "viewed"} + {viewedFilesStore === "environment" ? ( }> From b938067c16fbbfbd0e69bdf85e952725a663c407 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 2 Sep 2026 23:53:49 -0400 Subject: [PATCH 26/78] fix(server): correct viewed-file marks under partial answers and concurrency - A host that cannot answer for a path says nothing about that path, so reading silence as "the head has none of these files" reports every file a reader has already cleared as changed. - The marks are this environment's own rows. A rate limit or a signed-out CLI should cost them the staleness they would have carried, not the reader's whole record of what they have read. - Merging or updating a change request moves the head under a mark, so what was held about the old head no longer describes what the reader is looking at. - A press is one decision by the reader, and half of it landing is a state they never asked for. - A budget meant to cap what crosses the wire has to be counted in what actually crosses it. - Two presses on one change request are only ordered against each other if they agree on which gate to wait on. Signed-off-by: Yordis Prieto --- .../src/persistence/PullRequestFilesViewed.ts | 29 +++-- .../AzureDevOpsPullRequestProvider.ts | 3 +- .../pullRequest/GitLabPullRequestCli.test.ts | 41 +++++++ .../src/pullRequest/GitLabPullRequestCli.ts | 38 ++++--- .../pullRequest/PullRequestService.test.ts | 104 ++++++++++++++++++ .../src/pullRequest/PullRequestService.ts | 41 +++++-- .../server/src/pullRequest/azureDevOpsDiff.ts | 2 +- .../gitLabMergeRequestJson.test.ts | 36 ++++-- .../src/pullRequest/gitLabMergeRequestJson.ts | 17 ++- 9 files changed, 264 insertions(+), 47 deletions(-) diff --git a/apps/server/src/persistence/PullRequestFilesViewed.ts b/apps/server/src/persistence/PullRequestFilesViewed.ts index fada7d5271be..02d80481c602 100644 --- a/apps/server/src/persistence/PullRequestFilesViewed.ts +++ b/apps/server/src/persistence/PullRequestFilesViewed.ts @@ -107,11 +107,16 @@ export const make = Effect.gen(function* () { // the last few hundred milliseconds, so it is a handful of rows on a local database, and a // mixed batch of clears and un-clears has no single statement anyway. set: (input) => - Effect.forEach( - input.files, - (file) => - file.viewed - ? sql` + // One transaction for the batch. A press is a handful of files, and a failure part way + // through would otherwise leave some of them cleared and the rest not, which the reader + // sees on the next read as marks they never made. + sql + .withTransaction( + Effect.forEach( + input.files, + (file) => + file.viewed + ? sql` INSERT INTO pull_request_files_viewed ( provider, host, @@ -135,7 +140,7 @@ export const make = Effect.gen(function* () { ON CONFLICT (provider, host, repository, number, viewer, path) DO UPDATE SET revision = excluded.revision, viewed_at = excluded.viewed_at ` - : sql` + : sql` DELETE FROM pull_request_files_viewed WHERE provider = ${input.provider} AND host = ${input.host} @@ -144,12 +149,14 @@ export const make = Effect.gen(function* () { AND viewer = ${input.viewer} AND path = ${file.path} `, - { discard: true }, - ).pipe( - Effect.mapError( - (cause) => new PersistenceSqlError({ operation: "setPullRequestFilesViewed", cause }), + { discard: true }, + ), + ) + .pipe( + Effect.mapError( + (cause) => new PersistenceSqlError({ operation: "setPullRequestFilesViewed", cause }), + ), ), - ), }); }); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 8d22f0e03ff1..79203a491055 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -8,6 +8,7 @@ import { formatAzureDevOpsDiffCursor, parseAzureDevOpsDiffCursor, MAX_DIFF_SLICE_BYTES, + byteLength, MAX_FILE_DIFF_MILLIS, type AzureDevOpsFileTexts, } from "./azureDevOpsDiff.ts"; @@ -415,7 +416,7 @@ export const make = Effect.gen(function* () { ? azureDevOpsUnreadableFilePatch(change) : azureDevOpsFilePatch({ change, texts, timeoutMillis: MAX_FILE_DIFF_MILLIS }); sections.push(file.section); - bytes += file.section.length; + bytes += byteLength(file.section); truncated = truncated || file.truncated; index += 1; // A file whose diff was given up on spent the whole of what one file is allowed and has diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts index 2460d2e1ccb5..daa85ee9eb8f 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -1457,6 +1457,47 @@ layer("GitLabPullRequestCli.layer", (it) => { }), ); + it.effect("leaves the paths out when GitLab did not answer the blobs query", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + iid: 7, + title: "t", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, + }), + ), + ), + ); + // What GitLab says of a project the token cannot see. It is not the head having none of + // these files, and reading it that way would report every file the reader has cleared as + // changed on nothing worse than a permission. + mockedExecute.mockReturnValueOnce( + Effect.succeed( + // @effect-diagnostics-next-line preferSchemaOverJson:off + output(JSON.stringify({ data: { project: null } })), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const revisions = yield* cli.getFileRevisions({ + cwd: "/w", + repository: "acme/web", + number: 7, + paths: ["src/a.ts", "src/b.ts"], + }); + + expect([...revisions]).toEqual([]); + }), + ); + it.effect("asks GitLab nothing when no file is marked", () => Effect.gen(function* () { const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index dd0d3d8532b3..a7f6ff64e9f1 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -1057,7 +1057,7 @@ export const make = Effect.gen(function* () { readonly repository: string; readonly ref: string; readonly paths: ReadonlyArray; - }): Effect.Effect, GitLabPullRequestCliError> => + }): Effect.Effect | null, GitLabPullRequestCliError> => api({ cwd: input.cwd, path: "graphql", @@ -1068,7 +1068,7 @@ export const make = Effect.gen(function* () { }), }).pipe( Effect.flatMap( - (result): Effect.Effect, GitLabPullRequestCliError> => { + (result): Effect.Effect | null, GitLabPullRequestCliError> => { const decoded = decodeRepositoryBlobsJson(result.stdout.trim()); return Result.isSuccess(decoded) ? Effect.succeed(decoded.success) @@ -1100,20 +1100,32 @@ export const make = Effect.gen(function* () { } return Effect.forEach( batches, - (paths) => blobsAt({ ...input, ref: refs.headSha, paths }), + (paths) => + blobsAt({ ...input, ref: refs.headSha, paths }).pipe( + Effect.map((page) => ({ paths, page })), + ), { concurrency: 2 }, + ).pipe( + Effect.map((pages) => { + const revisions = new Map(); + for (const { paths, page } of pages) { + // A batch GitLab did not answer says nothing about its paths, so they are left + // out and the caller reads them as versions it could not learn, which leaves + // the marks on them alone. Filling them in as removed would report every file + // a reader has cleared as changed over a project the token cannot see. + if (page === null) continue; + for (const [path, oid] of page) revisions.set(path, oid); + // Within a batch that was answered, every path was looked for at the head, so + // one that is not there is one the merge request removed. Said as the empty + // revision, which is an answer the caller can compare against and keep. + for (const path of paths) { + if (!revisions.has(path)) revisions.set(path, ""); + } + } + return revisions as ReadonlyMap; + }), ); }), - Effect.map((pages) => { - const revisions = new Map(pages.flatMap((page) => [...page])); - // Every path was looked for at the head, so one that is not there is one the merge - // request removed rather than one this could not read. Said as the empty revision, - // which is an answer the caller can compare against and keep. - for (const path of input.paths) { - if (!revisions.has(path)) revisions.set(path, ""); - } - return revisions as ReadonlyMap; - }), ); const viewerUsername = (input: { readonly cwd: string }) => diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index d5d01d75e643..bbf4c325cded 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -4263,6 +4263,110 @@ it.effect("leaves a mark alone when the host could not say what the head has of }), ); +it.effect("keeps the version it last heard when a later read of the head stops short", () => + Effect.gen(function* () { + const asked: Array> = []; + const revisions = new Map([["src/a.ts", "blob-a"]]); + const unreadable = new Set(); + const service = yield* environmentViewedService(revisions, asked, unreadable); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + revisions.set("src/a.ts", "blob-a-again"); + yield* service.invalidate({ reference: GITLAB_REFERENCE }); + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "dismissed" }, + ]); + + // The read behind the next answer has to stop before this file. Forgetting the version it was + // last seen at would put the badge the reader has already been shown back to cleared, over an + // answer that said nothing about the file either way. + unreadable.add("src/a.ts"); + yield* TestClock.adjust("90 seconds"); + yield* service.filesViewed(GITLAB_REFERENCE); + yield* TestClock.adjust("20 seconds"); + + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "dismissed" }, + ]); + }), +); + +it.effect("forgets what the head had of a marked file once a mutation moves the head", () => + Effect.gen(function* () { + const revisions = new Map([["src/a.ts", "blob-a"]]); + const service = yield* environmentViewedService(revisions, []); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + // Merging moves the head under the mark, and nobody asks for the refresh: the mutation is + // the thing that knows, so it drops what it was holding rather than waiting to be told. + revisions.set("src/a.ts", "blob-a-again"); + yield* service.runAction({ ...GITLAB_REFERENCE, action: "merge" }); + + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "dismissed" }, + ]); + }), +); + +it.effect("still reports its own marks when the host will not say what the head has", () => + Effect.gen(function* () { + let answering = true; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("gitlab", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + viewedFiles: "environment", + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getFilesViewed: () => Effect.die("the host keeps no marks of its own"), + setFilesViewed: () => Effect.die("the host keeps no marks of its own"), + getFileRevisions: (input) => + answering + ? Effect.succeed({ + revisions: new Map(input.paths.map((path) => [path, "blob-a"] as const)), + }) + : Effect.fail(requestFailed), + }), + ], + }); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + answering = false; + yield* service.invalidate({ reference: GITLAB_REFERENCE }); + + // The rows are this environment's own. A rate limit or a signed-out CLI costs them the + // staleness they would have carried, not the reader's whole record of what they have read. + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "viewed" }, + ]); + }), +); + it.effect("finishes two presses on one file in the order they were made", () => Effect.gen(function* () { // A tick asks the host what it has of the file before it stores anything, and an untick asks diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 589b05277c23..86242db1d92d 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1482,10 +1482,10 @@ export const make = Effect.gen(function* () { }); /** - * What the head has of the files a reader has marked, held between reads. A path that was asked - * for and is not in the answer is one the head does not carry, which the marks read as the empty - * revision — so the entry remembers what it has been asked rather than treating every miss as an - * answer it never had. + * What the head has of the files a reader has marked, held between reads. A host says the empty + * revision for a file the change request deletes, and leaves out a path it could not look at, so + * the entry remembers what it has been asked as well as what it heard: a path asked for and + * missing from an answer keeps whatever version was last given for it. */ interface HeldFileRevisions { readonly at: number; @@ -1532,8 +1532,10 @@ export const make = Effect.gen(function* () { for (const path of paths) { asked.add(path); const revision = answer.get(path); - if (revision === undefined) revisions.delete(path); - else revisions.set(path, revision); + // Left out of the answer is the host not saying, not the head having nothing: deleting + // the version it last gave would turn a file already reported as changed back into a + // cleared one on the next answer that had to stop short. + if (revision !== undefined) revisions.set(path, revision); } heldFileRevisions.delete(scope); if (heldFileRevisions.size >= FILE_REVISIONS_CACHE_CAPACITY) { @@ -1636,11 +1638,22 @@ export const make = Effect.gen(function* () { .list(filesViewedScope(project, number, viewer)) .pipe(Effect.mapError(toFilesViewedStoreError("filesViewed"))); if (marks.length === 0) return { files: [], truncated: false }; + // These rows are this environment's own. A rate limit or a signed-out CLI costs the marks + // their staleness, which is the thing `fileRevisionsOf` already answers null for, and must + // not cost the reader every tick they have made. The press itself still fails loudly: a + // mark stamped with a revision nobody read is wrong rather than merely less informed. const revisions = yield* fileRevisionsOf( project, number, marks.map((mark) => mark.path), "filesViewed", + ).pipe( + Effect.catch((error) => + Effect.logWarning("reporting viewed files without what the head has of them", { + operation: "filesViewed", + reason: error._tag, + }).pipe(Effect.as(null)), + ), ); return { files: marks.map((mark) => { @@ -1679,15 +1692,19 @@ export const make = Effect.gen(function* () { number: number, write: Effect.Effect, ) => - Effect.gen(function* () { + // Suspended rather than generated, so finding the gate, putting it in and taking a place in + // its queue are one step. `Semaphore.make` is an effect, and yielding for it between the + // lookup and the insert lets two presses each find nothing, each make a gate of their own, + // and neither wait on the other, which is the ordering this exists for. + Effect.suspend(() => { const key = `${project.project.id} ${filesViewedRepositoryOf(project).trim().toLowerCase()} ${number}`; const held = filesViewedGates.get(key); - const entry = held ?? { gate: yield* Semaphore.make(1), pending: 0 }; + const entry = held ?? { gate: Semaphore.makeUnsafe(1), pending: 0 }; if (held === undefined) filesViewedGates.set(key, entry); entry.pending += 1; // Dropped once nobody is queued behind it, so a long-lived server does not keep a gate per // change request anyone has ever ticked a file in. - return yield* entry.gate + return entry.gate .withPermits(1)(write) .pipe( Effect.ensuring( @@ -2802,6 +2819,12 @@ export const make = Effect.gen(function* () { Effect.sync(() => { bumpRefEpoch(input); listingsEpoch = ++epochCounter; + // Not keyed by epoch, so this one is dropped by hand. Merging or bringing a stale + // branch up to date moves the head, and a mark compared against what the head had + // before it moved reports a file as cleared that has been pushed to since. + forgetFileRevisions( + fileRevisionsScope(input.projectId, input.repository, input.number), + ); }), ), ); diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts index 1ea63ac41edf..14bca3369aab 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -91,7 +91,7 @@ function isBinary(contents: string): boolean { * in characters lets a file of three-byte glyphs through at three times the size meant to be let * through. */ -const byteLength = (contents: string) => Buffer.byteLength(contents, "utf8"); +export const byteLength = (contents: string) => Buffer.byteLength(contents, "utf8"); /** * Git points an empty range at the line before it, which is line zero for a file that is wholly diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts index a6ace12807e3..bc1da091e366 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts @@ -633,8 +633,16 @@ describe("gitLabAwardName", () => { }); describe("decodeRepositoryBlobsJson", () => { + /** Null is the query going unanswered, which these cases are not about. */ + function expectBlobs(result: Result.Result | null, unknown>) { + const blobs = expectSuccess(result); + expect(blobs).not.toBe(null); + if (blobs === null) throw new Error("expected an answered blobs query"); + return blobs; + } + it("reads a blob id per path", () => { - const blobs = expectSuccess( + const blobs = expectBlobs( decodeRepositoryBlobsJson( JSON.stringify({ data: { @@ -660,7 +668,7 @@ describe("decodeRepositoryBlobsJson", () => { }); it("leaves out a node missing either half, which names no version", () => { - const blobs = expectSuccess( + const blobs = expectBlobs( decodeRepositoryBlobsJson( JSON.stringify({ data: { @@ -684,12 +692,24 @@ describe("decodeRepositoryBlobsJson", () => { expect([...blobs]).toEqual([["src/c.ts", "ccc333"]]); }); - it("reads a project the reader cannot see as no blobs rather than a failure", () => { - // The revision simply has none of the asked-for files, which is what a caller reads as - // "nothing here still stands" rather than as a read that broke. - expect([ - ...expectSuccess(decodeRepositoryBlobsJson(JSON.stringify({ data: { project: null } }))), - ]).toEqual([]); + it("tells a project the reader cannot see from a revision with none of the files", () => { + // Null is the query going unanswered. Read as an empty answer it would say the head has none + // of the asked-for files, which reports every file a reader has cleared as changed. + expect( + expectSuccess(decodeRepositoryBlobsJson(JSON.stringify({ data: { project: null } }))), + ).toBe(null); + expect( + expectSuccess( + decodeRepositoryBlobsJson(JSON.stringify({ data: { project: { repository: null } } })), + ), + ).toBe(null); + expect( + expectSuccess( + decodeRepositoryBlobsJson( + JSON.stringify({ data: { project: { repository: { blobs: { nodes: [] } } } } }), + ), + ), + ).toEqual(new Map()); }); it("fails on output that is not the query's shape", () => { diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts index 036068f08b49..f0ada6831a0e 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts @@ -1002,18 +1002,27 @@ const RawRepositoryBlobsSchema = Schema.Struct({ const decodeRepositoryBlobs = decodeJsonResult(RawRepositoryBlobsSchema); /** - * Blob ids by path. A node without both is left out: half an answer names no version, and the - * caller reads an absent path as "the revision does not have this file". + * Blob ids by path, or null where GitLab did not answer the query at all. + * + * A project the token cannot see comes back as `project: null`, and a repository can come back + * without a blobs connection, neither of which says anything about the paths that were asked for. + * That is worth telling apart from a connection that answered: read as "the revision has none of + * these files", an unanswered query reports every file a reader has cleared as changed. + * + * Within an answer, a node missing either half is left out, because half of one names no version, + * and the caller reads an absent path as one the revision does not carry. */ export function decodeRepositoryBlobsJson( raw: string, -): Result.Result, DecodeFailure> { +): Result.Result | null, DecodeFailure> { const decoded = decodeRepositoryBlobs(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } + const nodes = decoded.success.data.project?.repository?.blobs?.nodes; + if (nodes === undefined || nodes === null) return Result.succeed(null); const blobs = new Map(); - for (const node of decoded.success.data.project?.repository?.blobs?.nodes ?? []) { + for (const node of nodes) { const path = trimmed(node?.path); const oid = trimmed(node?.oid); if (path === null || oid === null) continue; From e8c53a02c04863c559e9d19e0b40682f30a8f1a7 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 3 Sep 2026 00:37:27 -0400 Subject: [PATCH 27/78] fix(server): keep a viewed mark honest when the host answers short A file's mark is only as good as the version it was measured against. Three places treated "the host did not say" as an answer in its own right, so a reader working through a long change on Azure DevOps or Bitbucket saw files they had just ticked come straight back as work to do. Paths keep whatever spaces their names carry, since the patch and the mark are keyed by the name the host gave, and the two Azure reads that can outgrow a megabyte now say so rather than failing as unreadable output. Signed-off-by: Yordis Prieto --- .../Migrations/047_PullRequestFilesViewed.ts | 9 +- .../src/persistence/PullRequestFilesViewed.ts | 12 +- .../AzureDevOpsPullRequestCli.test.ts | 111 +++++++++++++++--- .../pullRequest/AzureDevOpsPullRequestCli.ts | 17 +++ .../AzureDevOpsPullRequestProvider.ts | 5 + .../pullRequest/PullRequestService.test.ts | 64 ++++++++++ .../src/pullRequest/PullRequestService.ts | 26 +++- .../azureDevOpsPullRequestJson.test.ts | 24 ++++ .../pullRequest/azureDevOpsPullRequestJson.ts | 9 +- .../bitbucketDiffRevisions.test.ts | 83 +++++++++++++ .../src/pullRequest/bitbucketDiffRevisions.ts | 110 ++++++++++++++++- packages/contracts/src/pullRequest.test.ts | 40 +++++++ packages/contracts/src/pullRequest.ts | 9 +- 13 files changed, 487 insertions(+), 32 deletions(-) diff --git a/apps/server/src/persistence/Migrations/047_PullRequestFilesViewed.ts b/apps/server/src/persistence/Migrations/047_PullRequestFilesViewed.ts index 53183ccebbd7..675df1918a24 100644 --- a/apps/server/src/persistence/Migrations/047_PullRequestFilesViewed.ts +++ b/apps/server/src/persistence/Migrations/047_PullRequestFilesViewed.ts @@ -6,8 +6,11 @@ export default Effect.gen(function* () { // One row per file a reader has cleared on a host that keeps no record of its own. `revision` // is what the file was when it was cleared, so a push that changes it is reported as changed - // rather than silently left ticked. Unticking deletes the row: absent is the resting state, and - // a table of "not viewed" rows would grow with every diff anybody scrolled past. + // rather than silently left ticked. It is nullable because a host asked mid-press does not + // always answer: null is no baseline to compare against, which is not the same as the empty + // string, which is the host saying the head has nothing of the file. Unticking deletes the row: + // absent is the resting state, and a table of "not viewed" rows would grow with every diff + // anybody scrolled past. yield* sql` CREATE TABLE IF NOT EXISTS pull_request_files_viewed ( provider TEXT NOT NULL, @@ -16,7 +19,7 @@ export default Effect.gen(function* () { number INTEGER NOT NULL, viewer TEXT NOT NULL, path TEXT NOT NULL, - revision TEXT NOT NULL, + revision TEXT, viewed_at TEXT NOT NULL, PRIMARY KEY (provider, host, repository, number, viewer, path) ) WITHOUT ROWID diff --git a/apps/server/src/persistence/PullRequestFilesViewed.ts b/apps/server/src/persistence/PullRequestFilesViewed.ts index 02d80481c602..f9f663ed4aa1 100644 --- a/apps/server/src/persistence/PullRequestFilesViewed.ts +++ b/apps/server/src/persistence/PullRequestFilesViewed.ts @@ -35,11 +35,15 @@ export type PullRequestFilesViewedScope = typeof PullRequestFilesViewedScope.Typ export const PullRequestFileViewedMark = Schema.Struct({ path: Schema.String, /** - * The host's own name for that version of the file, opaque here. Empty where the host had none - * to give, which is its own answer rather than a missing one: a file with no version at the head - * is one the change request deletes, and it stays deleted. + * The host's own name for that version of the file, opaque here. + * + * Empty where the host said it had none to give, which is its own answer rather than a missing + * one: a file with no version at the head is one the change request deletes, and it stays + * deleted. Null where the host could not say at all, which is no baseline rather than an empty + * one: stamping such a mark with the empty revision would report the file as changed the moment + * anything did answer, so a mark with no baseline stays cleared until a press replaces it. */ - revision: Schema.String, + revision: Schema.NullOr(Schema.String), }); export type PullRequestFileViewedMark = typeof PullRequestFileViewedMark.Type; diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index 9f38a27108a0..497910512078 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -29,6 +29,21 @@ function output(stdout: string) { }; } +/** What VcsProcess allows a read that asked for no ceiling of its own. */ +const VCS_DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; + +/** + * The runner as it really behaves: it cuts stdout at the ceiling its caller asked for, and cuts + * it at the process default when the caller asked for none. A read whose response is larger than + * its ceiling gets JSON that stops mid-string, which is the whole cost of an unset ceiling. + */ +function outputWithin(maxOutputBytes: number | undefined, response: string) { + const ceiling = maxOutputBytes ?? VCS_DEFAULT_MAX_OUTPUT_BYTES; + return ceiling >= Buffer.byteLength(response) + ? output(response) + : { ...output(response.slice(0, ceiling)), stdoutTruncated: true }; +} + /** A fixture's own shape, spelled the way `az` would answer with it. */ const json = (value: Record) => JSON.stringify(value); @@ -80,6 +95,32 @@ function argsOfCall(index: number): ReadonlyArray { return call[0].args; } +/** The output ceiling the nth az invocation asked for, if it asked for one at all. */ +function maxOutputBytesOfCall(index: number) { + const call = mockedExecute.mock.calls[index]; + assert.isDefined(call); + return call[0].maxOutputBytes; +} + +/** A page of change entries the size Azure really answers with, url and object ids and all. */ +function changeEntries(count: number): ReadonlyArray> { + const commit = "c".repeat(40); + return Array.from({ length: count }, (_, index) => { + const path = `/apps/server/src/generated/module-${index}/persisted-projection-${index}.ts`; + return { + changeType: "edit", + item: { + path, + objectId: "a".repeat(40), + originalObjectId: "b".repeat(40), + commitId: commit, + gitObjectType: "blob", + url: `https://dev.azure.com/acme/platform/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/items${path}?versionType=Commit&version=${commit}`, + }, + }; + }); +} + afterEach(() => { mockedExecute.mockReset(); }); @@ -131,20 +172,9 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { 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, - }, - ); - }); + mockedExecute.mockImplementationOnce((input) => + Effect.succeed(outputWithin(input.maxOutputBytes, response)), + ); const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; const batch = yield* cli.listPullRequests({ @@ -1159,6 +1189,59 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { expect(argsOfCall(0)).toContain("project=platform"); expect(argsOfCall(0)).toContain("repositoryId=web"); expect(argsOfCall(0)).toContain("pullRequestId=42"); + // Threads are the reader's own words rather than a file's, so this one stays on whatever + // the process allows by default and the raised ceilings stay with the reads that need them. + assert.isUndefined(maxOutputBytesOfCall(0)); + }), + ); + + it.effect("reads a full page of change entries, which is past the default output limit", () => + Effect.gen(function* () { + const response = json({ changeEntries: changeEntries(2_000) }); + // Azure's own maximum for this route, and every entry carries a path, a url and three + // object ids, so an ordinary page of a large change already outgrows the default. + expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + mockedExecute.mockImplementationOnce((input) => + Effect.succeed(outputWithin(input.maxOutputBytes, response)), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const page = yield* cli.listIterationChanges({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + number: 42, + iterationId: 1, + }); + + // Cut at the default this would arrive as JSON stopping mid-string, and a perfectly + // ordinary page would be reported as a host answering with nonsense. + assert.strictEqual(page.changes.length, 2_000); + assert.isFalse(page.truncated); + }), + ); + + it.effect("reads a file whose JSON envelope is past the default output limit", () => + Effect.gen(function* () { + // Under a megabyte as bytes on the host, so this is a file the other hosts hand over. + const file = "const value = 1;\n".repeat(57_000); + const response = json({ content: file }); + expect(Buffer.byteLength(file)).toBeLessThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + // And past it once Azure wraps it, because there is no route here that serves the bytes. + expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + mockedExecute.mockImplementationOnce((input) => + Effect.succeed(outputWithin(input.maxOutputBytes, response)), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const item = yield* cli.readItemContent({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + path: "src/generated/schema.ts", + commit: "a".repeat(40), + }); + + assert.strictEqual(item.contents, file); + assert.isFalse(item.isBinary); }), ); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 34f6d068c916..0761f63264d0 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -119,6 +119,19 @@ 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; +/** + * A full page of change entries is two thousand files, each carrying its path, its url and + * several object ids, which is past the megabyte a read is given by default. Output cut at that + * ceiling arrives here as JSON that will not parse, so a change large enough to be paged would + * report itself as a host returning nonsense rather than as the ordinary page it is. + */ +const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +/** + * Four times the megabyte of file the other hosts hand over, because Azure has no route that + * serves the bytes themselves: the file arrives inside a JSON envelope, escaped if it is text and + * base64 if it is not, and both are larger than the file they carry. + */ +const ITEM_CONTENT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; /** Azure's own ceiling for one page of an iteration's changes. */ const CHANGE_ENTRIES_PER_PAGE = 2000; @@ -354,10 +367,12 @@ export const make = Effect.gen(function* () { readonly resource: string; readonly routeParameters: ReadonlyArray; readonly queryParameters?: ReadonlyArray; + readonly maxOutputBytes?: number; readonly decode: (raw: string) => Result.Result; }): Effect.Effect => executeJson({ cwd: input.cwd, + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), args: [ "devops", "invoke", @@ -597,6 +612,7 @@ export const make = Effect.gen(function* () { // Azure pages this route at 1000 entries by default; this is its own maximum per page, // and it names where the next page starts rather than answering with the whole change. queryParameters: [`$top=${CHANGE_ENTRIES_PER_PAGE}`, `$skip=${skip}`], + maxOutputBytes: CHANGE_ENTRIES_MAX_OUTPUT_BYTES, decode: decodeIterationChangesJson, }); const from = ( @@ -638,6 +654,7 @@ export const make = Effect.gen(function* () { // envelope, and `az devops invoke` refuses anything it cannot parse as JSON. "$format=json", ], + maxOutputBytes: ITEM_CONTENT_MAX_OUTPUT_BYTES, decode: decodeItemContentJson, }), diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index ae4cd198a17e..89a5ddc55d98 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -437,6 +437,11 @@ export const make = Effect.gen(function* () { // The patch is built from whole files, so opening the lines around a hunk is the same two // reads over again rather than a wider request. + // + // Read against the latest iteration, which is the one the patch was taken against unless a + // push landed in between. Nothing in the request says which push the reader is looking at, so + // there is no older iteration to go back to: expansion is stale after a mid-review push on + // every host here, and the diff it belongs to is stale with it. getDiffFileContents: (input) => Effect.gen(function* () { const scope = yield* diffScope(input); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index bbf4c325cded..f217250c9da0 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -4162,6 +4162,47 @@ it.effect("asks the host about a file it has not been asked about before", () => }), ); +it.effect("does not let a press about one file keep another file's version alive", () => + Effect.gen(function* () { + const asked: Array> = []; + const revisions = new Map([ + ["src/a.ts", "blob-a"], + ["src/b.ts", "blob-b"], + ]); + const service = yield* environmentViewedService(revisions, asked); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + yield* TestClock.adjust("40 seconds"); + // This press asks about its own file and carries the other one forward untouched. Counting + // the whole scope as heard from would put the first file's version back inside the window it + // had almost aged out of, and a reader working down a long diff renews it press after press. + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/b.ts", viewed: true }], + }); + assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]); + + revisions.set("src/a.ts", "blob-a-again"); + yield* TestClock.adjust("30 seconds"); + yield* service.filesViewed(GITLAB_REFERENCE); + assert.strictEqual(asked.length, 3); + + yield* TestClock.adjust("20 seconds"); + const caught = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual( + [...caught.files].toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { path: "src/a.ts", state: "dismissed" }, + { path: "src/b.ts", state: "viewed" }, + ], + ); + }), +); + it.effect("reports a file pushed to since it was cleared as changed", () => Effect.gen(function* () { const revisions = new Map([ @@ -4263,6 +4304,29 @@ it.effect("leaves a mark alone when the host could not say what the head has of }), ); +it.effect("keeps a file cleared that the press could not learn a version for", () => + Effect.gen(function* () { + // The press is the only moment a mark is given something to be measured against, and a host + // reading as much of a long change as it can manage does not always reach the file being + // ticked. Storing the empty version there reads as the head having nothing of the file, so the + // first read that does reach it reports the reader's own press back to them as work to do. + const revisions = new Map([["src/past-the-cut.ts", "blob-b"]]); + const unreadable = new Set(["src/past-the-cut.ts"]); + const service = yield* environmentViewedService(revisions, [], unreadable); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/past-the-cut.ts", viewed: true }], + }); + unreadable.delete("src/past-the-cut.ts"); + yield* service.invalidate({ reference: GITLAB_REFERENCE }); + + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/past-the-cut.ts", state: "viewed" }, + ]); + }), +); + it.effect("keeps the version it last heard when a later read of the head stops short", () => Effect.gen(function* () { const asked: Array> = []; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 0066c79fd91c..cf00e49dc2c8 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1534,7 +1534,14 @@ export const make = Effect.gen(function* () { const oldest = heldFileRevisions.keys().next().value; if (oldest !== undefined) heldFileRevisions.delete(oldest); } - heldFileRevisions.set(scope, { at, asked, revisions }); + // The entry is only as fresh as the oldest revision in it. Stamping it with now because + // this read answered would let a reader ticking one new file after another keep carrying the + // first file's revision past the point it would have been read again, since every press + // renews the whole scope while asking about one path. + const stamped = [...revisions.keys()].every((path) => answer.has(path)) + ? at + : (carried?.at ?? at); + heldFileRevisions.set(scope, { at: stamped, asked, revisions }); return revisions; }); @@ -1632,8 +1639,9 @@ export const make = Effect.gen(function* () { if (marks.length === 0) return { files: [], truncated: false }; // These rows are this environment's own. A rate limit or a signed-out CLI costs the marks // their staleness, which is the thing `fileRevisionsOf` already answers null for, and must - // not cost the reader every tick they have made. The press itself still fails loudly: a - // mark stamped with a revision nobody read is wrong rather than merely less informed. + // not cost the reader every tick they have made. The press itself still fails loudly on a + // host that errors, since a mark stamped with a revision nobody read is wrong rather than + // merely less informed; a host that answers without the path is stored with no baseline. const revisions = yield* fileRevisionsOf( project, number, @@ -1654,6 +1662,11 @@ export const make = Effect.gen(function* () { // as changed against a revision nobody read. A file the change request deletes is // answered as the empty revision, which is what its mark was stamped with, so it is // cleared once and stays cleared. + // A mark stamped with no baseline has nothing to compare against, so it holds until + // the reader presses it again. That is the press the host would not answer for, and + // reporting it as changed against a revision it was never measured at would move the + // file the reader just cleared back into the pile. + if (mark.revision === null) return { path: mark.path, state: "viewed" as const }; const revision = revisions?.get(mark.path); return { path: mark.path, @@ -1725,9 +1738,14 @@ export const make = Effect.gen(function* () { yield* filesViewedStore .set({ ...filesViewedScope(project, input.number, viewer), + // A path left out of the answer is the host declining to say, not the head having + // nothing of the file: the empty revision is an answer, and a mark stamped with it is + // reported as changed as soon as the file turns out to have a version after all. Such a + // mark is stored with no baseline instead, and a host too far behind to answer for a + // large change stays tickable rather than clearing files that come straight back. files: input.files.map((file) => ({ path: file.path, - revision: revisions?.get(file.path) ?? "", + revision: revisions?.get(file.path) ?? null, viewed: file.viewed, })), viewedAt, diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts index f026fe3fb8d5..9b707662cb27 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts @@ -400,6 +400,30 @@ describe("decodeIterationChangesJson", () => { ]); }); + it("keeps a space at the end of a file's name, which belongs to the name", () => { + // Git will carry a name that ends in a space, and the patch and the viewed mark are both + // keyed by it. Tidying it here files the change under a name nothing else uses. + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { changeType: "edit", item: { path: "/docs/readme.md ", objectId: "ec00" } }, + { + changeType: "rename", + sourceServerItem: "/docs/old.md ", + item: { path: "/docs/moved.md", objectId: "aaaa", originalObjectId: "aaaa" }, + }, + ], + }), + ), + ); + + expect(page.changes.map((change) => [change.path, change.oldPath])).toEqual([ + ["docs/readme.md ", "docs/readme.md "], + ["docs/moved.md", "docs/old.md "], + ]); + }); + it("reads a rename as one file that moved, and says whether it also changed", () => { const page = expectSuccess( decodeIterationChangesJson( diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts index e263c7457451..87c222e94edb 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts @@ -473,10 +473,15 @@ const decodeItemContent = decodeJsonResult(RawItemContentSchema); /** * Azure leads a path with a slash, which is its own spelling rather than part of the name. Every * other host, and every patch, names the same file without it. + * + * Not trimmed, unlike everything else read out of this payload: a leading or trailing space is a + * legal part of a file's name, and a path trimmed here no longer matches the one the patch and the + * viewed mark are keyed by, so the file is filed under a name nothing else uses. */ function toRepositoryPath(value: string | null | undefined): string | null { - const path = trimmed(value); - return path === null ? null : path.replace(/^\/+/, ""); + if (value === undefined || value === null) return null; + const path = value.replace(/^\/+/, ""); + return path.length === 0 ? null : path; } /** diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts b/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts index 572548309b75..f4401eb7a036 100644 --- a/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts +++ b/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts @@ -130,4 +130,87 @@ describe("parseDiffFileRevisions", () => { it("reads nothing out of an empty patch", () => { assert.strictEqual(parseDiffFileRevisions("").size, 0); }); + + it("reads a name git had to quote, here one holding a tab", () => { + const revisions = parseDiffFileRevisions( + patchOf( + 'diff --git "a/we\\tird.ts" "b/we\\tird.ts"', + "index 4444444..5555555 100644", + '--- "a/we\\tird.ts"', + '+++ "b/we\\tird.ts"', + "@@ -1 +1 @@", + "-a", + "+b", + ), + ); + + assert.deepStrictEqual([...revisions], [["we\tird.ts", "5555555"]]); + }); + + it("rejoins the octal bytes git writes for a name outside ASCII", () => { + const revisions = parseDiffFileRevisions( + patchOf( + 'diff --git "a/caf\\303\\251/r\\303\\251sum\\303\\251.ts" "b/caf\\303\\251/r\\303\\251sum\\303\\251.ts"', + "index 6666666..7777777 100644", + "@@ -1 +1 @@", + ), + ); + + assert.deepStrictEqual([...revisions], [["café/résumé.ts", "7777777"]]); + }); + + it("splits a rename header where git quoted only the side that needed it", () => { + const revisions = parseDiffFileRevisions( + patchOf( + 'diff --git a/old.ts "b/new\\tname.ts"', + "similarity index 90%", + "rename from old.ts", + 'rename to "new\\tname.ts"', + "index 8888888..9999999 100644", + "--- a/old.ts", + '+++ "b/new\\tname.ts"', + "@@ -1 +1 @@", + "-a", + "+b", + ), + ); + + assert.deepStrictEqual([...revisions], [["new\tname.ts", "9999999"]]); + }); + + it("names a quoted rename that changed nothing, which states no paths of its own", () => { + const revisions = parseDiffFileRevisions( + patchOf( + 'diff --git "a/old\\tname.ts" "b/new\\tname.ts"', + "similarity index 100%", + 'rename from "old\\tname.ts"', + 'rename to "new\\tname.ts"', + "index abcabca..abcabca 100644", + ), + ); + + assert.deepStrictEqual([...revisions], [["new\tname.ts", "abcabca"]]); + }); + + it("keeps a character from outside the basic plane that git left unescaped", () => { + // `core.quotePath` off leaves the name's own bytes in place, and git still quotes the header + // for the tab. Encoding what it left one unit at a time would split the pair into two halves. + const revisions = parseDiffFileRevisions( + patchOf( + 'diff --git "a/we\\tird-\u{1f680}.ts" "b/we\\tird-\u{1f680}.ts"', + "index aaaaaaa..bbbbbbb 100644", + "@@ -1 +1 @@", + ), + ); + + assert.deepStrictEqual([...revisions], [["we\tird-\u{1f680}.ts", "bbbbbbb"]]); + }); + + it("splits an unquoted header whose names hold a space, by the sides agreeing", () => { + const revisions = parseDiffFileRevisions( + patchOf("diff --git a/one two b/one two", "index ddddddd..eeeeeee 100644", "@@ -1 +1 @@"), + ); + + assert.deepStrictEqual([...revisions], [["one two", "eeeeeee"]]); + }); }); diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts index af915bc9e069..d02b1d310fee 100644 --- a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts +++ b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts @@ -1,4 +1,20 @@ const ENTRY = "diff --git "; +const QUOTE = '"'; + +const NAMED_ESCAPES: Record = { + '"': 0x22, + "\\": 0x5c, + a: 0x07, + b: 0x08, + f: 0x0c, + n: 0x0a, + r: 0x0d, + t: 0x09, + v: 0x0b, +}; + +const utf8 = new TextEncoder(); +const fromUtf8 = new TextDecoder(); interface Entry { oldPath: string | null; @@ -9,10 +25,85 @@ interface Entry { inBody: boolean; } +/** + * The name inside git's quoted form, which git reaches for when a name holds a tab, a newline, a + * quote, a backslash, or, under `core.quotePath`, any byte outside ASCII. + * + * The escapes are per byte, so a name in any other alphabet arrives as a run of octal and only + * reads back as itself once those bytes are rejoined and decoded together. A name git had no + * reason to quote is already the name. + */ +function unquotePath(token: string): string { + if (token.length < 2 || !token.startsWith(QUOTE) || !token.endsWith(QUOTE)) return token; + const body = token.slice(1, -1); + const bytes: Array = []; + // Anything git left as itself is encoded a run at a time rather than a unit at a time, so a + // character written outside the basic plane keeps its pair together and comes back as itself + // instead of as two halves neither of which is a character. + let literal = ""; + const flush = () => { + if (literal.length === 0) return; + bytes.push(...utf8.encode(literal)); + literal = ""; + }; + let at = 0; + while (at < body.length) { + const char = body.charAt(at); + if (char !== "\\") { + literal += char; + at += 1; + continue; + } + const escaped = body.charAt(at + 1); + if (escaped === "") { + flush(); + bytes.push(0x5c); + break; + } + const named = NAMED_ESCAPES[escaped]; + if (named !== undefined) { + flush(); + bytes.push(named); + at += 2; + continue; + } + const octal = body.slice(at + 1, at + 4); + if (/^[0-7]{3}$/.test(octal)) { + flush(); + bytes.push(Number.parseInt(octal, 8)); + at += 4; + continue; + } + literal += escaped; + at += 2; + } + flush(); + return fromUtf8.decode(new Uint8Array(bytes)); +} + +/** Where a quoted name closes, given git escapes every quote the name itself holds. */ +function quotedEnd(rest: string): number { + for (let at = 1; at < rest.length; at += 1) { + const char = rest.charAt(at); + if (char === "\\") { + at += 1; + continue; + } + if (char === QUOTE) return at; + } + return -1; +} + /** `a/x` and `b/x` on a `---` or `+++` line; `/dev/null` is the side that has no file. */ function sidePath(rest: string, prefix: string): string | null { if (rest === "/dev/null") return null; - return rest.startsWith(prefix) ? rest.slice(prefix.length) : rest; + const path = unquotePath(rest); + return path.startsWith(prefix) ? path.slice(prefix.length) : path; +} + +function headerSide(token: string, prefix: string): string | null { + const path = unquotePath(token); + return path.startsWith(prefix) ? path.slice(prefix.length) : null; } /** @@ -21,8 +112,21 @@ function sidePath(rest: string, prefix: string): string | null { * `a/one two b/one two` splits in more than one place, so the split that leaves both sides equal * wins. A rename is the only entry whose sides differ, and a rename states its names on lines of * its own. Anything still ambiguous is left unnamed rather than guessed at. + * + * A quoted name ends at its own closing quote, so a header carrying one splits there and needs + * none of that guessing. Git quotes only the side that needs it, so one side can be quoted alone. */ function headerPaths(rest: string): readonly [string | null, string | null] { + if (rest.startsWith(QUOTE)) { + const end = quotedEnd(rest); + if (end === -1 || rest.charAt(end + 1) !== " ") return [null, null]; + return [headerSide(rest.slice(0, end + 1), "a/"), headerSide(rest.slice(end + 2), "b/")]; + } + if (rest.endsWith(QUOTE)) { + const opens = rest.indexOf(QUOTE); + if (opens < 1 || rest.charAt(opens - 1) !== " ") return [null, null]; + return [headerSide(rest.slice(0, opens - 1), "a/"), headerSide(rest.slice(opens), "b/")]; + } if (!rest.startsWith("a/")) return [null, null]; const splits: Array = []; for (let at = rest.indexOf(" b/"); at !== -1; at = rest.indexOf(" b/", at + 1)) splits.push(at); @@ -82,9 +186,9 @@ export function parseDiffFileRevisions(patch: string): ReadonlyMap { expect(pullRequestRepositoryOf(identity({ provider: "github" }))).toBeNull(); }); }); + +describe("naming the file a tick belongs to", () => { + // A space on either end of a name is part of the name as far as git is concerned. The patch on + // screen and the environment's record of what was cleared are both keyed by it, so a path + // tidied in transit ticks a file that does not exist and leaves the one on screen unticked. + it("keeps the spaces around a path being ticked", () => { + expect( + decodeSetFilesViewed({ + projectId: "p1", + repository: "group/project", + number: 7, + files: [{ path: "docs/readme.md ", viewed: true }], + }).files, + ).toEqual([{ path: "docs/readme.md ", viewed: true }]); + }); + + it("keeps the spaces around a path being reported back", () => { + expect( + decodeFilesViewed({ + files: [{ path: " leading.md", state: "viewed" }], + truncated: false, + }).files, + ).toEqual([{ path: " leading.md", state: "viewed" }]); + }); + + it("still refuses a path that is nothing at all", () => { + expect(() => + decodeSetFilesViewed({ + projectId: "p1", + repository: "group/project", + number: 7, + files: [{ path: "", viewed: true }], + }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 557d98e83f80..89e19fb30c2b 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -907,6 +907,11 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({ }); export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileContentsResult.Type; +// Not trimmed: a leading or trailing space is a legal part of a file's name, and both the patch +// and the environment's own record of what a reader cleared are keyed by the name the host gave. +// Trimming it here files the mark under a name nothing else uses, so the tick never comes back. +const FilePath = Schema.String.check(Schema.isNonEmpty()); + /** * Where one file of a change request stands with the person reading it. * @@ -919,7 +924,7 @@ export const PullRequestFileViewedState = Schema.Literals(["unviewed", "viewed", export type PullRequestFileViewedState = typeof PullRequestFileViewedState.Type; export const PullRequestFileViewed = Schema.Struct({ - path: TrimmedNonEmptyString, + path: FilePath, state: PullRequestFileViewedState, }); export type PullRequestFileViewed = typeof PullRequestFileViewed.Type; @@ -953,7 +958,7 @@ export const PullRequestSetFilesViewedInput = Schema.Struct({ ...PullRequestRef.fields, files: Schema.Array( Schema.Struct({ - path: TrimmedNonEmptyString, + path: FilePath, viewed: Schema.Boolean, }), ), From 66395776b9e0edbd8f980893f0a966a38cf2963c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 3 Sep 2026 00:46:17 -0400 Subject: [PATCH 28/78] fix(server): give a long review's own history room to arrive Two Azure reads answer with the whole of a review at once and neither pages, so they grow with how long the review ran rather than with how large the change is. Cut at the process default they arrive as JSON that stops mid-string, and the iterations read failing takes every diff and file revision on the host with it. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestCli.test.ts | 110 +++++++++++++++++- .../pullRequest/AzureDevOpsPullRequestCli.ts | 12 ++ 2 files changed, 119 insertions(+), 3 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index 497910512078..3daa4f022dbc 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -121,6 +121,66 @@ function changeEntries(count: number): ReadonlyArray> { }); } +/** An Azure identity, which rides along with every comment and every push Azure answers with. */ +function identity(name: string) { + const id = "6f9c9b7f-0000-0000-0000-000000000000"; + return { + displayName: name, + id, + uniqueName: `${name.toLowerCase().replace(/ /g, ".")}@acme.com`, + descriptor: `aad.${"z".repeat(52)}`, + imageUrl: `https://dev.azure.com/acme/_api/_common/identityImage?id=${id}`, + url: `https://spsprodweu1.vssps.visualstudio.com/A${id}/_apis/Identities/${id}`, + _links: { + avatar: { + href: `https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.${"z".repeat(52)}`, + }, + }, + }; +} + +/** A review's threads the shape Azure answers with, system threads and identities and all. */ +function threadRows(count: number): ReadonlyArray> { + return Array.from({ length: count }, (_, index) => ({ + id: index + 1, + publishedDate: "2026-07-02T00:00:00Z", + lastUpdatedDate: "2026-07-02T00:00:00Z", + status: "active", + threadContext: { filePath: `/apps/server/src/generated/module-${index}.ts` }, + identities: { 1: identity("Reviewer One") }, + isDeleted: false, + comments: [ + { + id: 1, + parentCommentId: 0, + author: identity("Reviewer One"), + content: `Comment ${index}: ${"this needs another look. ".repeat(20)}`, + publishedDate: "2026-07-02T00:00:00Z", + lastUpdatedDate: "2026-07-02T00:00:00Z", + commentType: "text", + usersLiked: [], + }, + ], + })); +} + +/** A review's iterations the shape Azure answers with, one per push. */ +function iterationRows(count: number): ReadonlyArray> { + return Array.from({ length: count }, (_, index) => ({ + id: index + 1, + description: `Pushed ${index} commits`, + author: identity("Author One"), + createdDate: "2026-07-02T00:00:00Z", + updatedDate: "2026-07-02T00:00:00Z", + sourceRefCommit: { commitId: `${index}`.padStart(40, "a") }, + targetRefCommit: { commitId: "b".repeat(40) }, + commonRefCommit: { commitId: "c".repeat(40) }, + hasMultipleCommits: true, + reason: "push", + push: { pushId: index + 1, date: "2026-07-02T00:00:00Z", pushedBy: identity("Author One") }, + })); +} + afterEach(() => { mockedExecute.mockReset(); }); @@ -1189,9 +1249,53 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { expect(argsOfCall(0)).toContain("project=platform"); expect(argsOfCall(0)).toContain("repositoryId=web"); expect(argsOfCall(0)).toContain("pullRequestId=42"); - // Threads are the reader's own words rather than a file's, so this one stays on whatever - // the process allows by default and the raised ceilings stay with the reads that need them. - assert.isUndefined(maxOutputBytesOfCall(0)); + // A review's threads grow with how long it ran, and this route does not page, so the read + // asks for more than the process default rather than taking whatever it is given. + expect(maxOutputBytesOfCall(0)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + }), + ); + + it.effect("reads a long review's threads, which are past the default output limit", () => + Effect.gen(function* () { + const response = json({ value: threadRows(800) }); + // Azure opens a thread per vote and per ref update beside the ones people wrote, and every + // comment carries a full identity, so a review argued over for weeks outgrows the default. + expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + mockedExecute.mockImplementationOnce((input) => + Effect.succeed(outputWithin(input.maxOutputBytes, response)), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const comments = yield* cli.listThreads({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + number: 42, + }); + + assert.strictEqual(comments.length, 800); + }), + ); + + it.effect("reads a long review's iterations, which are past the default output limit", () => + Effect.gen(function* () { + const response = json({ value: iterationRows(1_200) }); + // This route does not page, so the whole history arrives at once. Cut at the default it is + // JSON stopping mid-string, and every diff and file revision read on this host fails with + // it, since each of them starts by asking which iteration is the latest. + expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + mockedExecute.mockImplementationOnce((input) => + Effect.succeed(outputWithin(input.maxOutputBytes, response)), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const iterations = yield* cli.listIterations({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + number: 42, + }); + + assert.strictEqual(iterations.length, 1_200); + assert.strictEqual(iterations.at(-1)?.id, 1_200); }), ); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 0761f63264d0..a65112f9941a 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -132,6 +132,16 @@ const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; * base64 if it is not, and both are larger than the file they carry. */ const ITEM_CONTENT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; +/** + * What a review's own history is given. Neither of these routes pages, so each answers with the + * whole of it at once and grows with how long the review ran rather than with how large the change + * is. Threads are the nearer ceiling of the two: Azure opens one per vote and per ref update + * alongside the ones people wrote, and every comment carries a full identity beside its text, so + * the answer is far larger than the handful of fields read back out of it. Cut at the default, + * both arrive as JSON that stops mid-string, and a long review would report its host as answering + * with nonsense. + */ +const REVIEW_HISTORY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; /** Azure's own ceiling for one page of an iteration's changes. */ const CHANGE_ENTRIES_PER_PAGE = 2000; @@ -590,6 +600,7 @@ export const make = Effect.gen(function* () { operation: "listThreads", resource: "pullRequestThreads", routeParameters: pullRequestRoute(input), + maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, decode: decodeThreadsJson, }), @@ -599,6 +610,7 @@ export const make = Effect.gen(function* () { operation: "listIterations", resource: "pullRequestIterations", routeParameters: pullRequestRoute(input), + maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, decode: decodeIterationsJson, }), From 051fa5ad964e75133fdbd6c64d32bc744baf05c9 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 3 Sep 2026 08:28:43 -0400 Subject: [PATCH 29/78] fix(server): refuse a diff cursor Azure's reader never handed out Signed-off-by: Yordis Prieto --- apps/server/src/pullRequest/azureDevOpsDiff.test.ts | 9 +++++++++ apps/server/src/pullRequest/azureDevOpsDiff.ts | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts index 63b790b0a068..3d6ab8d39830 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts @@ -220,4 +220,13 @@ describe("a diff cursor", () => { expect(parseAzureDevOpsDiffCursor(raw)).toBeNull(); } }); + + it("refuses a half it did not write rather than reading it as the first file", () => { + // `Number` is wider than the cursor: an empty, padded or hex half would otherwise pass as a + // position, and the read would resume against an iteration the client never saw instead of + // starting again from the latest one. + for (const raw of ["1:", ":4", "1: ", " 1:4", "1:0x2", "0x1:2", "1e2:0", "1:4.0"]) { + expect(parseAzureDevOpsDiffCursor(raw)).toBeNull(); + } + }); }); diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts index 14bca3369aab..f8a451ae483f 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -15,6 +15,13 @@ export interface AzureDevOpsDiffCursor { const CURSOR_SEPARATOR = ":"; +/** + * Both halves are plain decimal, because `Number` is wider than what was written: it reads an + * empty or padded half as zero and `0x3` as three, so a cursor this did not write would resume + * from a position nothing ever handed out. + */ +const CURSOR_COMPONENT = /^\d+$/; + export function formatAzureDevOpsDiffCursor(cursor: AzureDevOpsDiffCursor): string { return `${cursor.iterationId}${CURSOR_SEPARATOR}${cursor.fileIndex}`; } @@ -26,6 +33,8 @@ export function parseAzureDevOpsDiffCursor( if (raw === null || raw === undefined) return null; const [iteration, file, ...rest] = raw.split(CURSOR_SEPARATOR); if (rest.length > 0) return null; + if (iteration === undefined || file === undefined) return null; + if (!CURSOR_COMPONENT.test(iteration) || !CURSOR_COMPONENT.test(file)) return null; const iterationId = Number(iteration); const fileIndex = Number(file); if (!Number.isSafeInteger(iterationId) || iterationId <= 0) return null; From 83784099887d19d67750bc861361776c5dd9b1c8 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 3 Sep 2026 08:28:44 -0400 Subject: [PATCH 30/78] fix(server): key a GitLab blob by the path the host spelled Signed-off-by: Yordis Prieto --- .../gitLabMergeRequestJson.test.ts | 33 +++++++++++++++++++ .../src/pullRequest/gitLabMergeRequestJson.ts | 8 +++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts index bc1da091e366..5bb5264e7306 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts @@ -692,6 +692,39 @@ describe("decodeRepositoryBlobsJson", () => { expect([...blobs]).toEqual([["src/c.ts", "ccc333"]]); }); + it("keys a blob by the path the host spelled, spaces and all", () => { + // A leading or trailing space is a legal part of a file's name. Trimmed here, the id lands + // under a key the asked-for path is not spelled with, and the caller fills that path in as + // the empty revision: a mark on the file then never compares against the real head blob. + const blobs = expectBlobs( + decodeRepositoryBlobsJson( + JSON.stringify({ + data: { + project: { + repository: { + blobs: { + nodes: [ + { path: " leading.ts", oid: "aaa111" }, + { path: "trailing.ts ", oid: "bbb222" }, + { path: " ", oid: "ccc333" }, + { path: "", oid: "ddd444" }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect([...blobs]).toEqual([ + [" leading.ts", "aaa111"], + ["trailing.ts ", "bbb222"], + // A name that is only spaces is one Git carries too, so it is a path like any other. + [" ", "ccc333"], + ]); + }); + it("tells a project the reader cannot see from a revision with none of the files", () => { // Null is the query going unanswered. Read as an empty answer it would say the head has none // of the asked-for files, which reports every file a reader has cleared as changed. diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts index f0ada6831a0e..4c35d4539ca6 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts @@ -1023,9 +1023,13 @@ export function decodeRepositoryBlobsJson( if (nodes === undefined || nodes === null) return Result.succeed(null); const blobs = new Map(); for (const node of nodes) { - const path = trimmed(node?.path); + // Not trimmed, unlike everything else read out of this payload: a leading or trailing space + // is a legal part of a file's name, so a path trimmed here is filed under a key neither the + // asked-for path nor the viewed mark is spelled with, and the caller reads the file it was + // asked about as one this revision does not carry. + const path = node?.path; const oid = trimmed(node?.oid); - if (path === null || oid === null) continue; + if (path === undefined || path === null || path.length === 0 || oid === null) continue; blobs.set(path, oid); } return Result.succeed(blobs); From 6ec0e317736fad7f058a2c277a119737e2cad962 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 3 Sep 2026 08:28:45 -0400 Subject: [PATCH 31/78] fix(server): ask Azure for a file by its own spelling of the path Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestCli.test.ts | 19 +++++++++++++++++++ .../pullRequest/AzureDevOpsPullRequestCli.ts | 9 ++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index 3daa4f022dbc..6e378c43b1b6 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -1349,6 +1349,25 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect("asks for a file by Azure's own spelling of its path", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(json({ content: "const a = 1;" })))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + // The path carried around here has had Azure's leading slash taken off so it matches the + // patch and the viewed mark. The items route is documented in Azure's spelling, so it goes + // back on the way out rather than being sent as the shorter name. + yield* cli.readItemContent({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + path: "src/app.ts", + commit: "a".repeat(40), + }); + + expect(argsOfCall(0)).toContain("path=/src/app.ts"); + }), + ); + it.effect("reports a pull request it cannot place as its own outcome", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index a65112f9941a..f90fa5f3683c 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -415,6 +415,13 @@ export const make = Effect.gen(function* () { }), ); + /** + * Azure names its own items with a leading slash, which the repository paths carried around + * here have had taken off so they match the patch and the viewed mark. Put it back on the way + * out, because the items route is documented in Azure's own spelling. + */ + const toItemPath = (path: string) => (path.startsWith("/") ? path : `/${path}`); + const repositoryRoute = (location: AzureDevOpsRepositoryLocation): ReadonlyArray => [ `project=${location.project}`, `repositoryId=${location.repository}`, @@ -654,7 +661,7 @@ export const make = Effect.gen(function* () { resource: "items", routeParameters: repositoryRoute(input.location), queryParameters: [ - `path=${input.path}`, + `path=${toItemPath(input.path)}`, "versionDescriptor.versionType=commit", `versionDescriptor.version=${input.commit}`, "includeContent=true", From aeafc2787330c575aead5d39ef78344dd4033549 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 7 Sep 2026 15:50:54 -0400 Subject: [PATCH 32/78] fix(web): an Azure review's preview and its ticks agree on the repository's name Azure addresses a repository by its bare name, which is unique inside a project and not across an organisation. The preview target and the viewed-file scope's fallback each still spelled it their own way, so a preview was turned away at the door and two repositories called the same thing could share one row. Signed-off-by: Yordis Prieto --- apps/server/src/pullRequest/PullRequestService.ts | 10 ++++++++-- apps/web/src/components/ChatMarkdown.tsx | 2 +- apps/web/src/lib/openPullRequestLink.test.ts | 9 +++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a0f1716db4e3..0c3943e7dc64 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1468,8 +1468,14 @@ export const make = Effect.gen(function* () { */ const filesViewedRepositoryOf = (project: SupportedProject) => { if (project.api.kind !== "azure-devops") return project.repository; - const path = project.project.repositoryIdentity?.displayName?.trim(); - return path === undefined || path.length === 0 ? project.repository : path; + const identity = project.project.repositoryIdentity; + const path = identity?.displayName?.trim(); + if (path !== undefined && path.length > 0) return path; + // The scope carries no project id, so the bare name Azure is addressed by would put two + // repositories called `api` on one row. The canonical remote repeats the host this scope + // already holds, and is the only other spelling that keeps the whole identity. + const canonical = identity?.canonicalKey?.trim(); + return canonical === undefined || canonical.length === 0 ? project.repository : canonical; }; /** diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 1a1ceb2dd6c3..daf02e96fd17 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2775,7 +2775,7 @@ const CHAT_MARKDOWN_COMPONENTS = { input: { projectId: pullRequestProject.id, repository: - pullRequestProject.repositoryIdentity?.displayName ?? + pullRequestRepositoryOf(pullRequestProject.repositoryIdentity) ?? pullRequestCandidate.repository, number: pullRequestCandidate.number, }, diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index e7403b605978..26ed01c7bc53 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -10,6 +10,7 @@ import { shouldOpenPullRequestExternally, } from "./openPullRequestLink"; import { ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; function repositoryIdentity( provider: string, @@ -342,11 +343,15 @@ describe("findProjectForChangeRequest", () => { // Azure alone addresses one repository under two names: `ssh.dev.azure.com` and `v3/...` over // SSH against `dev.azure.com` and `.../_git/...` everywhere a person sees it. The identity is // recorded in the spelling a link arrives in, so both halves of this comparison line up. + // + // Derived from the SSH remote the way the server derives it rather than written out, so the + // day that normalization stops reaching the web spelling this fails here too. + const canonicalKey = normalizeGitRemoteUrl("git@ssh.dev.azure.com:v3/T3Tools/Platform/T3Code"); const projects = [ project({ - canonicalKey: "dev.azure.com/t3tools/platform/_git/t3code", + canonicalKey, provider: "azure-devops", - displayName: "t3tools/platform/_git/t3code", + displayName: canonicalKey.split("/").slice(1).join("/"), owner: "t3tools", name: "t3code", }), From d622f4ec487892928a25938cae85189299c9b7f9 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 13:30:58 -0400 Subject: [PATCH 33/78] chore(server): stop exporting helpers only their own module reads Upstream widened knip's export check to apps/server and apps/desktop, and the Azure rewrite here left the organization-base helper without an outside caller. Signed-off-by: Yordis Prieto --- apps/server/src/persistence/PullRequestFilesViewed.ts | 2 +- apps/server/src/sourceControl/azureDevOpsPullRequests.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/server/src/persistence/PullRequestFilesViewed.ts b/apps/server/src/persistence/PullRequestFilesViewed.ts index f9f663ed4aa1..342a8c51593b 100644 --- a/apps/server/src/persistence/PullRequestFilesViewed.ts +++ b/apps/server/src/persistence/PullRequestFilesViewed.ts @@ -81,7 +81,7 @@ function toSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { : new PersistenceSqlError({ operation: sqlOperation, cause }); } -export const make = Effect.gen(function* () { +const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const listRows = SqlSchema.findAll({ diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index d11d1a87077a..6179a61aa65d 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -77,11 +77,11 @@ function encodeAzureDevOpsPathSegment(segment: string): string { } /** - * The organization root a REST url belongs to, which is where a browser url and any further - * REST call have to be hung. Exported because the pull requests page derives its own urls from - * whatever Azure returned rather than from the local remote, whose shape varies. + * The organization root a REST url belongs to, which is where a browser url has to be hung when + * Azure answered with neither a web link nor a repository url. Read from what Azure returned + * rather than from the local remote, whose shape varies. */ -export function azureDevOpsOrganizationBaseFromRestApiUrl( +function azureDevOpsOrganizationBaseFromRestApiUrl( value: string | null | undefined, ): string | null { const rawUrl = trimOptionalString(value); From 82d8bb614fceab59eba678f514442a4fc395f36b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 16:20:37 -0400 Subject: [PATCH 34/78] docs(web): sentBy guards a repress while a request is out, not overlapping requests Signed-off-by: Yordis Prieto --- .../components/pullRequest/usePullRequestFilesViewed.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index f3c638ee5dba..7e69dd807da6 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -74,10 +74,9 @@ export function usePullRequestFilesViewed(options: { }); // Presses waiting for the next flush, and, for every path a request is already carrying, which - // request that is. Requests overlap and run in the order they were made, so a path pressed - // again while an earlier one is still out belongs to the later request from that moment on, and - // the earlier one stops answering for it. Both are refs rather than state: nothing on screen - // reads them, and the flush must see the latest. + // request that is. A path pressed again while its request is out belongs to the later request + // from then on, and the earlier one stops answering for it. Both are refs rather than state: + // nothing on screen reads them, and the flush must see the latest. const queued = useRef>(new Map()); const sentBy = useRef>(new Map()); const requests = useRef(0); From f4abc08eabcc7d71913e3d270e7c8a81a4c0531c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 16:20:38 -0400 Subject: [PATCH 35/78] perf(web): a tick or a fold no longer rehashes every annotation on the page Signed-off-by: Yordis Prieto --- .../pullRequest/PullRequestCodeTab.tsx | 64 ++++++++++++------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 02afbf546c78..b76989bdfa8f 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -420,7 +420,13 @@ function PullRequestCodeTab({ enabled: viewedFilesStore !== undefined, paths: filePaths, }); - const { setViewed, refresh: refreshFilesViewed } = filesViewed; + const { + setViewed, + refresh: refreshFilesViewed, + enabled: filesViewedEnabled, + isViewed: isFileViewed, + isStale: isFileViewedStale, + } = filesViewed; // The button goes around the host's cache, so everything the tab reads from it starts over: // the diff from its first page, and with it the ticks, which a push since the last read can // have marked as standing against an older version of the file. @@ -464,7 +470,9 @@ function PullRequestCodeTab({ return placed; }, [commit, detail.reviewThreads, files]); - const items = useMemo[]>( + // Hashing what the annotations show is the costly part of an item's version, and none of it + // moves when a file is ticked or folded, so it is kept apart from the two that do. + const annotatedFiles = useMemo( () => files.map((fileDiff) => { const fileKey = buildFileDiffRenderKey(fileDiff); @@ -506,29 +514,20 @@ function PullRequestCodeTab({ groupAt(anchor.side, anchor.line).draft = true; } - const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); - // The header carries the reader's own tick, and the viewer redraws a file only when its - // version moves. Ticking a file that is already folded changes no fold, so without this - // the box on screen would keep saying the opposite of what the count says. - const viewedMark = filesViewed.enabled - ? `e${filesViewed.isViewed(path) ? "v" : ""}${filesViewed.isStale(path) ? "s" : ""}` - : ""; - const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({ side: toViewerSide(group.side), lineNumber: group.line, metadata: { threads: group.threads, pending: group.pending, draft: group.draft }, })); return { - id: fileKey, - type: "diff" as const, + fileKey, + path, fileDiff, annotations, - collapsed, // The viewer re-renders an item only when its version changes, so everything the // annotations show has to be part of it. - version: fnv1a32( - `${collapsed ? "1" : "0"}:${viewedMark}:${annotations + annotationsVersion: fnv1a32( + annotations .map( ({ side, lineNumber, metadata }) => `${side}:${lineNumber}:${metadata.draft ? "d" : ""}:${metadata.pending @@ -553,19 +552,38 @@ function PullRequestCodeTab({ ) .join(",")}`, ) - .join("|")}`, + .join("|"), ), }; }), + [commit, detail.reviewThreads, draft, files, pendingComments, placedThreadIds], + ); + + const items = useMemo[]>( + () => + annotatedFiles.map(({ fileKey, path, fileDiff, annotations, annotationsVersion }) => { + const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); + // The header carries the reader's own tick, and the viewer redraws a file only when its + // version moves. Ticking a file that is already folded changes no fold, so without this + // the box on screen would keep saying the opposite of what the count says. + const viewedMark = filesViewedEnabled + ? `e${isFileViewed(path) ? "v" : ""}${isFileViewedStale(path) ? "s" : ""}` + : ""; + return { + id: fileKey, + type: "diff" as const, + fileDiff, + annotations, + collapsed, + version: fnv1a32(`${collapsed ? "1" : "0"}:${viewedMark}:${annotationsVersion}`), + }; + }), [ - commit, - detail.reviewThreads, - draft, - files, - filesViewed, + annotatedFiles, + filesViewedEnabled, foldOverride, - pendingComments, - placedThreadIds, + isFileViewed, + isFileViewedStale, toggledFiles, ], ); From 413cfc8c688192f09b0412f9d9850e147a340db1 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 16:20:39 -0400 Subject: [PATCH 36/78] fix(web): the viewed checkbox announces the label a reader can see Signed-off-by: Yordis Prieto --- apps/web/src/components/pullRequest/PullRequestCodeTab.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index b76989bdfa8f..c9c94aecd803 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -844,10 +844,8 @@ function PullRequestCodeTab({ className="flex cursor-pointer select-none items-center gap-1.5 text-[11px] text-muted-foreground" onClick={(event) => event.stopPropagation()} > - {/* Named here rather than by the label, whose text turns into "Changed" once the - file has been pushed to. */} setFileViewedRef.current(item.id, path, next === true)} /> From 814290e2726079a97f2b6f739d6d40927e6a131b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 16:25:18 -0400 Subject: [PATCH 37/78] fix(server): a bitbucket path holding a space no longer reads as viewed forever Signed-off-by: Yordis Prieto --- .../bitbucketDiffRevisions.test.ts | 64 +++++++++++++++++++ .../src/pullRequest/bitbucketDiffRevisions.ts | 15 ++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts b/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts index f4401eb7a036..1426a67d29da 100644 --- a/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts +++ b/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts @@ -206,6 +206,70 @@ describe("parseDiffFileRevisions", () => { assert.deepStrictEqual([...revisions], [["we\tird-\u{1f680}.ts", "bbbbbbb"]]); }); + it("drops the tab git ends a name holding a space with", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/with space.txt b/with space.txt", + "index 1111111..6178079 100644", + "--- a/with space.txt\t", + "+++ b/with space.txt\t", + "@@ -1 +1 @@", + "-a", + "+b", + ), + ); + + assert.deepStrictEqual([...revisions], [["with space.txt", "6178079"]]); + }); + + it("drops that tab from a quoted name too, where it lands past the closing quote", () => { + const revisions = parseDiffFileRevisions( + patchOf( + 'diff --git "a/we ird\\tname.ts" "b/we ird\\tname.ts"', + "index 2222222..7777777 100644", + '--- "a/we ird\\tname.ts"\t', + '+++ "b/we ird\\tname.ts"\t', + "@@ -1 +1 @@", + "-a", + "+b", + ), + ); + + assert.deepStrictEqual([...revisions], [["we ird\tname.ts", "7777777"]]); + }); + + it("reads a deletion whose name holds a space, tab and all", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/with space.txt b/with space.txt", + "deleted file mode 100644", + "index 3333333..0000000", + "--- a/with space.txt\t", + "+++ /dev/null", + "@@ -1 +0,0 @@", + "-a", + ), + ); + + assert.deepStrictEqual([...revisions], [["with space.txt", "0000000"]]); + }); + + it("drops the timestamp other producers of the format write past that tab", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/stamped.ts b/stamped.ts", + "index 4444444..5555555 100644", + "--- a/stamped.ts\t2024-01-01 00:00:00.000000000 +0000", + "+++ b/stamped.ts\t2024-01-02 00:00:00.000000000 +0000", + "@@ -1 +1 @@", + "-a", + "+b", + ), + ); + + assert.deepStrictEqual([...revisions], [["stamped.ts", "5555555"]]); + }); + it("splits an unquoted header whose names hold a space, by the sides agreeing", () => { const revisions = parseDiffFileRevisions( patchOf("diff --git a/one two b/one two", "index ddddddd..eeeeeee 100644", "@@ -1 +1 @@"), diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts index d02b1d310fee..125333de7491 100644 --- a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts +++ b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts @@ -94,10 +94,19 @@ function quotedEnd(rest: string): number { return -1; } -/** `a/x` and `b/x` on a `---` or `+++` line; `/dev/null` is the side that has no file. */ +/** + * `a/x` and `b/x` on a `---` or `+++` line; `/dev/null` is the side that has no file. + * + * Git ends these two lines with a tab when the name holds a space, so that a reader can tell where + * the name stops, and other producers of the format put a timestamp past that tab. A name holding a + * tab of its own arrives quoted, with that tab written as an escape, so the first literal tab is + * never part of what the file is called and everything from it on belongs to git. + */ function sidePath(rest: string, prefix: string): string | null { - if (rest === "/dev/null") return null; - const path = unquotePath(rest); + const tab = rest.indexOf("\t"); + const token = tab === -1 ? rest : rest.slice(0, tab); + if (token === "/dev/null") return null; + const path = unquotePath(token); return path.startsWith(prefix) ? path.slice(prefix.length) : path; } From 47b1409afa8af42f7a0a9768a4d414537d454a96 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 16:25:19 -0400 Subject: [PATCH 38/78] perf(server): a run of ticks no longer downloads the bitbucket patch once each Signed-off-by: Yordis Prieto --- .../BitbucketPullRequestApi.test.ts | 62 +++++++++++++++++++ .../pullRequest/BitbucketPullRequestApi.ts | 41 +++++++++++- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index 52ee64f4c6a9..6a5af425d05e 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -1,6 +1,8 @@ import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; @@ -431,6 +433,66 @@ layer("BitbucketPullRequestApi.layer", (it) => { }), ); + it.effect("reads the patch once for a run of ticks, not once a tick", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue( + Effect.succeed( + response( + [ + "diff --git a/a.ts b/a.ts", + "index 1111111..2222222 100644", + "@@ -1 +1 @@", + "diff --git a/b.ts b/b.ts", + "index 3333333..4444444 100644", + "@@ -1 +1 @@", + "", + ].join("\n"), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const first = yield* api.getFileRevisions({ + repository: "acme/web", + number: 74, + paths: ["a.ts"], + }); + // A path nobody has asked about before, which is what every tick after the first names. + const second = yield* api.getFileRevisions({ + repository: "acme/web", + number: 74, + paths: ["b.ts"], + }); + + assert.deepStrictEqual([...first], [["a.ts", "2222222"]]); + assert.deepStrictEqual([...second], [["b.ts", "4444444"]]); + assert.strictEqual(mockedRequest.mock.calls.length, 1); + }), + ); + + it.effect("reads the patch afresh once the one it held has aged out", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue( + Effect.succeed( + response("diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n"), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + const read = () => + api.getFileRevisions({ repository: "acme/web", number: 75, paths: ["a.ts"] }); + + yield* read(); + yield* read(); + assert.strictEqual(mockedRequest.mock.calls.length, 1); + + // Well inside the window the caller holds versions for: a refresh drops what it holds so + // that the read after it reaches Bitbucket, and this must not answer that read instead. + yield* TestClock.adjust(Duration.seconds(30)); + yield* read(); + assert.strictEqual(mockedRequest.mock.calls.length, 2); + }), + ); + it.effect("asks Bitbucket nothing when no file has been ticked off", () => Effect.gen(function* () { const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index e087b5f56e7b..6e1fe8ae52d6 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -1,5 +1,8 @@ +import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; +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 Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -136,6 +139,18 @@ const CONVERSATION_PAGE_SIZE = 50; const CONVERSATION_PAGES = 10; /** The same ceiling the gh and glab diff reads use. */ const DIFF_MAX_BYTES = 8 * 1024 * 1024; +/** + * How long one read of a pull request's patch keeps answering the version reads behind it, and how + * many pull requests are held that way at once. + * + * A reader ticking files off names one new path at a time, and a path the caller has not asked + * about before is a path it cannot answer from what it holds, so without this every tick pays for + * the whole patch again. Deliberately far shorter than the window the caller holds versions for: + * a refresh drops what the caller holds precisely so the next read reaches Bitbucket, and this + * must not be what answers it instead. + */ +const REVISION_PATCH_TTL = Duration.seconds(5); +const REVISION_PATCH_CAPACITY = 16; export interface BitbucketPullRequestBatch { readonly items: ReadonlyArray; readonly truncated: boolean; @@ -189,8 +204,10 @@ export class BitbucketPullRequestApi extends Context.Service< * path the patch does not carry is answered as the empty revision, and left out altogether * when the patch was cut short at the byte ceiling and so cannot be spoken for. * - * Held by the caller rather than here: the marks and the badge they feed share one window, - * and a second one underneath it would keep answering after a refresh had asked it not to. + * The versions themselves are held by the caller rather than here: the marks and the badge + * they feed share one window, and a second one underneath it would keep answering after a + * refresh had asked it not to. The patch they are read out of is held for a few seconds, which + * is what keeps a reader ticking one file after another from downloading it once per tick. */ readonly getFileRevisions: (input: { readonly repository: string; @@ -569,6 +586,24 @@ export const make = Effect.gen(function* () { ), ); + /** + * The pull request's whole patch, shared by the version reads that come one tick at a time. A + * second tick arriving while the first read is still in flight waits on that read rather than + * starting another. + */ + const revisionPatches = yield* Cache.makeWith( + (key: string) => { + const [repository, number] = JSON.parse(key) as [string, number]; + return pullRequestDiff({ repository, number }); + }, + { + capacity: REVISION_PATCH_CAPACITY, + // A failure is not held: the tick after it should reach Bitbucket rather than be handed the + // same error for as long as a good patch would have lasted. + timeToLive: (exit) => (Exit.isSuccess(exit) ? REVISION_PATCH_TTL : Duration.zero), + }, + ); + return BitbucketPullRequestApi.of({ getViewer: () => bitbucket.request({ method: "GET", url: "/user" }).pipe( @@ -645,7 +680,7 @@ export const make = Effect.gen(function* () { getFileRevisions: (input) => input.paths.length === 0 ? Effect.succeed(new Map()) - : pullRequestDiff({ repository: input.repository, number: input.number }).pipe( + : Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe( Effect.map((diff) => { const all = parseDiffFileRevisions(diff.patch); // Narrowed to what was asked for rather than handed back whole: the caller compares From 99c466289b6658943225a4b6fa67c659061ae6e1 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 16:25:20 -0400 Subject: [PATCH 39/78] perf(server): writing to a github pull request no longer re-reads its node id Signed-off-by: Yordis Prieto --- .../pullRequest/GitHubPullRequestCli.test.ts | 84 +++++++++++++++++-- .../src/pullRequest/GitHubPullRequestCli.ts | 25 +++++- 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index ed60b036337e..7eddc9d07574 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -2278,11 +2278,12 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + // Its own pull request: a node id looked up once is remembered for the life of the service. yield* cli.setReaction({ cwd: "/w", repository: "acme/web", host: "github.com", - number: 7, + number: 21, content: "rocket", reacted: true, }); @@ -2291,7 +2292,7 @@ layer("GitHubPullRequestCli.layer", (it) => { const lookup = callAt(0).args; expect(lookup).toContain("owner=acme"); expect(lookup).toContain("name=web"); - expect(lookup).toContain("number=7"); + expect(lookup).toContain("number=21"); // @effect-diagnostics-next-line preferSchemaOverJson:off const request = JSON.parse(callAt(1).stdin ?? "") as { query: string; @@ -2352,7 +2353,7 @@ layer("GitHubPullRequestCli.layer", (it) => { cwd: "/w", repository: "acme/web", host: "github.com", - number: 7, + number: 22, ...fields, }); @@ -2360,21 +2361,21 @@ layer("GitHubPullRequestCli.layer", (it) => { yield* rewrite({ body: "A better description." }); yield* rewrite({ title: "Both", body: "at once." }); - // Each rewrite looks the pull request's node id up first, then mutates. + // One node id lookup for the pull request, then a mutation per rewrite. const variablesAt = (index: number) => (JSON.parse(callAt(index).stdin ?? "") as { variables: Record }).variables; expect(variablesAt(1)).toEqual({ pullRequestId: "PR_kwDOA", title: "A better title" }); - expect(variablesAt(3)).toEqual({ + expect(variablesAt(2)).toEqual({ pullRequestId: "PR_kwDOA", body: "A better description.", }); - expect(variablesAt(5)).toEqual({ + expect(variablesAt(3)).toEqual({ pullRequestId: "PR_kwDOA", title: "Both", body: "at once.", }); // The reader's own words, so they travel the way every other body does. - expect(callAt(5).args.join(" ")).not.toContain("at once."); + expect(callAt(3).args.join(" ")).not.toContain("at once."); }), ); @@ -3200,7 +3201,7 @@ layer("GitHubPullRequestCli.layer", (it) => { cwd: "/w", repository: "acme/web", host: "github.com", - number: 7, + number: 23, files: [ { path: "src/a.ts", viewed: true }, { path: "src/b.ts", viewed: false }, @@ -3239,4 +3240,71 @@ layer("GitHubPullRequestCli.layer", (it) => { assert.strictEqual(mockedExecute.mock.calls.length, 0); }), ); + + it.effect("looks a pull request's node id up once, however often it is written to", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + // @effect-diagnostics-next-line preferSchemaOverJson:off + output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_24" } } } })), + ), + ) + .mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const pullRequest = { cwd: "/w", repository: "acme/web", host: "github.com", number: 24 }; + + yield* cli.setPullRequestFilesViewed({ + ...pullRequest, + files: [{ path: "src/a.ts", viewed: true }], + }); + yield* cli.setPullRequestFilesViewed({ + ...pullRequest, + files: [{ path: "src/b.ts", viewed: true }], + }); + yield* cli.updatePullRequest({ ...pullRequest, title: "Ticked through" }); + + // One lookup, then a mutation per write, every one of them addressed by the id it answered. + assert.strictEqual(mockedExecute.mock.calls.length, 4); + expect(callAt(0).args).toContain("number=24"); + const idSentAt = (index: number) => + (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } }) + .variables.pullRequestId; + expect([idSentAt(1), idSentAt(2), idSentAt(3)]).toEqual(["PR_24", "PR_24", "PR_24"]); + }), + ); + + it.effect("does not remember a node id lookup that failed", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))) + .mockReturnValueOnce( + Effect.succeed( + // @effect-diagnostics-next-line preferSchemaOverJson:off + output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_25" } } } })), + ), + ) + .mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const write = () => + cli.setPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 25, + files: [{ path: "src/a.ts", viewed: true }], + }); + + const error = yield* Effect.flip(write()); + assert.strictEqual(error._tag, "GitHubPullRequestReadError"); + + yield* write(); + + assert.strictEqual(mockedExecute.mock.calls.length, 3); + const idSentAt = (index: number) => + (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } }) + .variables.pullRequestId; + expect(idSentAt(2)).toEqual("PR_25"); + }), + ); }); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 5ca92a1253bd..7123c6068bf3 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1028,15 +1028,26 @@ export const make = Effect.gen(function* () { /** * The pull request's own node id, which is what a mutation against the pull request itself is * addressed by: a reaction on its description, or a rewrite of its words. + * + * A pull request keeps its node id for life, so it is remembered rather than re-read: a reader + * ticking files viewed would otherwise pay a GraphQL round trip per press. Bounded and + * oldest-first, since a long-lived server sees far more pull requests than a reader ever has + * open. */ + const NODE_ID_CACHE_CAPACITY = 128; + const nodeIds = new Map(); + const pullRequestNodeId = (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly number: number; readonly operation: string; - }) => { + }): Effect.Effect => { const { owner, name } = parseRepositorySelector(input.repository); + const key = `${input.host} ${owner}/${name} ${input.number}`; + const held = nodeIds.get(key); + if (held !== undefined) return Effect.succeed(held); return graphqlRead({ cwd: input.cwd, host: input.host, @@ -1049,7 +1060,17 @@ export const make = Effect.gen(function* () { ], query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, decode: decodePullRequestNodeIdJson, - }); + }).pipe( + Effect.tap((nodeId) => + Effect.sync(() => { + if (nodeIds.size >= NODE_ID_CACHE_CAPACITY) { + const oldest = nodeIds.keys().next().value; + if (oldest !== undefined) nodeIds.delete(oldest); + } + nodeIds.set(key, nodeId); + }), + ), + ); }; /** From 382eaee8001646cd5eeabbcdffcd497c0aecd2cc Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 16:42:05 -0400 Subject: [PATCH 40/78] fix(server): a signed-out cli no longer hides the files a reader marked viewed Signed-off-by: Yordis Prieto --- .../pullRequest/PullRequestService.test.ts | 82 +++++++++++++++++++ .../src/pullRequest/PullRequestService.ts | 28 +++++-- 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 820fb52dc519..648162da8268 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -4779,6 +4779,88 @@ it.effect("keeps environment marks apart from another change request's", () => }), ); +/** The environment-backed fixture with its own answer to who the reader is. */ +const environmentViewedServiceWithViewer = ( + revisions: Map, + getViewer: PullRequestProviderApi["getViewer"], +) => + makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [{ ...environmentViewedProvider(revisions, []), getViewer }], + }); + +it.effect("keeps one reader's marks on a host that names nobody", () => + Effect.gen(function* () { + const service = yield* environmentViewedServiceWithViewer( + new Map([["src/a.ts", "blob-a"]]), + () => Effect.succeed(""), + ); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "viewed" }, + ]); + }), +); + +it.effect("refuses the marks when the host could not be asked who is reading", () => + Effect.gen(function* () { + let answering = true; + const service = yield* environmentViewedServiceWithViewer( + new Map([["src/a.ts", "blob-a"]]), + () => + answering + ? Effect.succeed("bilal") + : Effect.fail( + new PullRequestProviderError({ + provider: "gitlab", + operation: "getViewer", + reason: "failed", + detail: "glab exited with status 1", + }), + ), + ); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + // Who is signed in is held for ten minutes, so the lookup has to come round again before a + // failing CLI can reach the read at all. + answering = false; + yield* TestClock.adjust("11 minutes"); + + // Answering these from the unnamed reader's rows would show the reader none of their own + // ticks, and file the next press where the recovered CLI will never look for it again. + const read = yield* Effect.flip(service.filesViewed(GITLAB_REFERENCE)); + const write = yield* Effect.flip( + service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/b.ts", viewed: true }], + }), + ); + assert.strictEqual(read._tag, "PullRequestOperationError"); + assert.strictEqual(write._tag, "PullRequestOperationError"); + + answering = true; + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "viewed" }, + ]); + }), +); + it.effect("refuses to track viewed files on a host that does not", () => Effect.gen(function* () { const service = yield* makeService({ diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 0c3943e7dc64..43e24c296893 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1480,9 +1480,8 @@ export const make = Effect.gen(function* () { /** * Which change request's marks, and whose. The host is part of it because the same - * `owner/repo` exists on more than one install, and the reader is part of it for the reason - * a host's own record is per-account. A host that will not say who is reading leaves it - * empty, which is one reader rather than none. + * `owner/repo` exists on more than one install, and the reader is part of it for the reason a + * host's own record is per-account. A host that names no reader is one reader, not none. */ const filesViewedScope = (project: SupportedProject, number: number, viewer: string | null) => ({ provider: project.api.kind, @@ -1499,6 +1498,25 @@ export const make = Effect.gen(function* () { cause, }); + /** + * Who the host says the reader is, for the paths whose rows are keyed by it. A lookup that + * failed is refused rather than answered as the unnamed reader: a rate-limited or momentarily + * signed-out CLI would otherwise hide every tick this reader has made and file the next press + * under rows that are orphaned once it recovers. + */ + const requiredViewerOf = ( + project: SupportedProject, + operation: string, + ): Effect.Effect => + resolveViewers([project], new Map()).pipe( + Effect.flatMap(([resolved]) => { + const error = resolved?.error ?? null; + return error === null + ? Effect.succeed(resolved?.viewer ?? null) + : Effect.fail(toPullRequestError(operation)(error)); + }), + ); + /** * What the head has of the files a reader has marked, held between reads. A host says the empty * revision for a file the change request deletes, and leaves out a path it could not look at, so @@ -1658,7 +1676,7 @@ export const make = Effect.gen(function* () { number: number, ): Effect.Effect => Effect.gen(function* () { - const viewer = yield* viewerOf(project); + const viewer = yield* requiredViewerOf(project, "filesViewed"); const marks = yield* filesViewedStore .list(filesViewedScope(project, number, viewer)) .pipe(Effect.mapError(toFilesViewedStoreError("filesViewed"))); @@ -1752,7 +1770,7 @@ export const make = Effect.gen(function* () { input: PullRequestSetFilesViewedInput, ): Effect.Effect => Effect.gen(function* () { - const viewer = yield* viewerOf(project); + const viewer = yield* requiredViewerOf(project, "setFilesViewed"); // Only the files being cleared need a revision. An unticked one is about to lose its row, // and what the head has of it changes nothing about deleting it. const cleared = input.files.filter((file) => file.viewed).map((file) => file.path); From e33e684df34fc58b1ca772b5ef33db978c9c4694 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 16:42:27 -0400 Subject: [PATCH 41/78] fix(server): a workspace refresh re-asks the head what it has of the marked files Signed-off-by: Yordis Prieto --- .../pullRequest/PullRequestService.test.ts | 20 ++++ .../src/pullRequest/PullRequestService.ts | 101 +++++++----------- 2 files changed, 56 insertions(+), 65 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 648162da8268..9a54e4731511 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -4573,6 +4573,26 @@ it.effect("keeps the version it last heard when a later read of the head stops s }), ); +it.effect("re-asks what the head has of a marked file after a whole-workspace refresh", () => + Effect.gen(function* () { + const revisions = new Map([["src/a.ts", "blob-a"]]); + const service = yield* environmentViewedService(revisions, []); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + // A push nobody told this environment about. No single reference has moved, so the held + // answer goes only because the refresh is the reader asking for all of it to be read again. + revisions.set("src/a.ts", "blob-a-again"); + yield* service.invalidate({}); + + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "dismissed" }, + ]); + }), +); + it.effect("forgets what the head had of a marked file once a mutation moves the head", () => Effect.gen(function* () { const revisions = new Map([["src/a.ts", "blob-a"]]); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 43e24c296893..b52959b075d3 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1530,33 +1530,30 @@ export const make = Effect.gen(function* () { } const heldFileRevisions = new Map(); const refreshingFileRevisions = new Set(); + /** Bumped by a whole-workspace refresh, the one drop no single reference's epoch covers. */ + let everyFileRevisionEpoch = 0; /** - * Moved every time a held answer is dropped. A refresh already in flight when that happens - * still answers its own caller, and its answer is simply not kept: it was taken against a head - * the reader has since asked to stop believing, and keeping it would put the dropped entry - * straight back. One counter for every scope rather than one each, so an unrelated refresh - * costs an in-flight read its place in the cache and nothing else. + * Carries the reference's epoch like the read it serves, so whatever moved the head strands + * what was held against the old one, including an answer still in flight, which stores under + * the key it began with. Normalised, because a reference arrives spelled however the client + * spelled it while the project carries the remote's own spelling. */ - let fileRevisionsGeneration = 0; - /** - * Normalised, because a reference reaches here spelled however the client spelled it while the - * project carries the remote's own spelling, and a refresh that missed by a capital would leave - * the held answer standing. - */ - const fileRevisionsScope = (projectId: string, repository: string, number: number) => - `${projectId} ${repository.trim().toLowerCase()} ${number}`; + const fileRevisionsKey = (ref: PullRequestRef) => + [ + refEpoch(ref), + everyFileRevisionEpoch, + ref.projectId, + ref.repository.trim().toLowerCase(), + ref.number, + ].join(" "); const recordFileRevisions = ( - scope: string, + key: string, paths: ReadonlyArray, answer: ReadonlyMap, - generation: number, ) => Effect.map(Clock.currentTimeMillis, (at) => { - // Answered from, never stored: the caller asked for this and it is as fresh as anything - // could be, but the scope it belongs to has been dropped since the read began. - if (generation !== fileRevisionsGeneration) return answer; - const held = heldFileRevisions.get(scope); + const held = heldFileRevisions.get(key); // Past the stale window the old entry is not worth merging into: it would carry paths // nobody has asked about since, at revisions the head has long moved off. const carried = @@ -1573,7 +1570,7 @@ export const make = Effect.gen(function* () { // cleared one on the next answer that had to stop short. if (revision !== undefined) revisions.set(path, revision); } - heldFileRevisions.delete(scope); + heldFileRevisions.delete(key); if (heldFileRevisions.size >= FILE_REVISIONS_CACHE_CAPACITY) { const oldest = heldFileRevisions.keys().next().value; if (oldest !== undefined) heldFileRevisions.delete(oldest); @@ -1585,28 +1582,18 @@ export const make = Effect.gen(function* () { const stamped = [...revisions.keys()].every((path) => answer.has(path)) ? at : (carried?.at ?? at); - heldFileRevisions.set(scope, { at: stamped, asked, revisions }); + heldFileRevisions.set(key, { at: stamped, asked, revisions }); return revisions; }); /** A held entry that covers every path asked for and is still worth answering from. */ - const heldFileRevisionsFor = (scope: string, paths: ReadonlyArray, now: number) => { - const held = heldFileRevisions.get(scope); + const heldFileRevisionsFor = (key: string, paths: ReadonlyArray, now: number) => { + const held = heldFileRevisions.get(key); if (held === undefined) return null; if (now - held.at > Duration.toMillis(FILE_REVISIONS_STALE_WINDOW)) return null; return paths.every((path) => held.asked.has(path)) ? held : null; }; - const forgetFileRevisions = (scope: string) => { - heldFileRevisions.delete(scope); - fileRevisionsGeneration += 1; - }; - - const forgetEveryFileRevision = () => { - heldFileRevisions.clear(); - fileRevisionsGeneration += 1; - }; - /** * What the head has of these files, or null where the host cannot say. Null is not an error: * without it the marks simply stop reporting staleness, which is worse than the host's own @@ -1619,43 +1606,43 @@ export const make = Effect.gen(function* () { */ const fileRevisionsOf = ( project: SupportedProject, - number: number, + ref: PullRequestRef, paths: ReadonlyArray, operation: string, freshness: "held" | "fresh" = "held", ): Effect.Effect | null, PullRequestError> => { const read = project.api.getFileRevisions; if (read === undefined) return Effect.succeed(null); - const scope = fileRevisionsScope(project.project.id, project.repository, number); // Suspended, so a held answer costs the host nothing: a provider is free to do its work as // the request is built rather than as the effect is run. const fetch = Effect.suspend(() => { - const generation = fileRevisionsGeneration; + const key = fileRevisionsKey(ref); return read({ cwd: project.project.workspaceRoot, repository: project.repository, host: project.host, - number, + number: ref.number, paths, }).pipe( Effect.mapError(toPullRequestError(operation)), - Effect.flatMap((answer) => recordFileRevisions(scope, paths, answer.revisions, generation)), + Effect.flatMap((answer) => recordFileRevisions(key, paths, answer.revisions)), ); }); return Effect.flatMap(Clock.currentTimeMillis, (now) => { - const held = heldFileRevisionsFor(scope, paths, now); + const key = fileRevisionsKey(ref); + const held = heldFileRevisionsFor(key, paths, now); if (held === null) return fetch; if (now - held.at <= Duration.toMillis(FILE_REVISIONS_CACHE_TTL)) return Effect.succeed(held.revisions); if (freshness === "fresh") return fetch; - if (refreshingFileRevisions.has(scope)) return Effect.succeed(held.revisions); + if (refreshingFileRevisions.has(key)) return Effect.succeed(held.revisions); // Its own fiber rather than a child: the caller has been answered and is gone before this // lands. One at a time per change request, so a page of files costs one host read. return Effect.sync(() => { - refreshingFileRevisions.add(scope); + refreshingFileRevisions.add(key); runFork( Effect.ignore(fetch).pipe( - Effect.ensuring(Effect.sync(() => refreshingFileRevisions.delete(scope))), + Effect.ensuring(Effect.sync(() => refreshingFileRevisions.delete(key))), ), ); }).pipe(Effect.as(held.revisions)); @@ -1673,12 +1660,12 @@ export const make = Effect.gen(function* () { */ const environmentFilesViewed = ( project: SupportedProject, - number: number, + ref: PullRequestRef, ): Effect.Effect => Effect.gen(function* () { const viewer = yield* requiredViewerOf(project, "filesViewed"); const marks = yield* filesViewedStore - .list(filesViewedScope(project, number, viewer)) + .list(filesViewedScope(project, ref.number, viewer)) .pipe(Effect.mapError(toFilesViewedStoreError("filesViewed"))); if (marks.length === 0) return { files: [], truncated: false }; // These rows are this environment's own. A rate limit or a signed-out CLI costs the marks @@ -1688,7 +1675,7 @@ export const make = Effect.gen(function* () { // merely less informed; a host that answers without the path is stored with no baseline. const revisions = yield* fileRevisionsOf( project, - number, + ref, marks.map((mark) => mark.path), "filesViewed", ).pipe( @@ -1777,7 +1764,7 @@ export const make = Effect.gen(function* () { const revisions = cleared.length === 0 ? null - : yield* fileRevisionsOf(project, input.number, cleared, "setFilesViewed", "fresh"); + : yield* fileRevisionsOf(project, input, cleared, "setFilesViewed", "fresh"); const viewedAt = DateTime.formatIso(yield* DateTime.now); yield* filesViewedStore .set({ @@ -1810,7 +1797,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.mapError(toPullRequestError("filesViewed"))); } if (project.api.capabilities.viewedFiles === "environment") { - return environmentFilesViewed(project, input.number); + return environmentFilesViewed(project, input); } return Effect.fail( new PullRequestOperationError({ @@ -2919,20 +2906,14 @@ export const make = Effect.gen(function* () { const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { const reference = input.reference; if (reference !== undefined) { - return Effect.sync(() => { - bumpRefEpoch(reference); - // Not keyed by epoch, so this one is dropped by hand rather than stranded. - forgetFileRevisions( - fileRevisionsScope(reference.projectId, reference.repository, reference.number), - ); - }); + return Effect.sync(() => bumpRefEpoch(reference)); } // A whole-workspace refresh is the reader asking to be re-answered from the hosts, // and that includes who the hosts say they are. return Effect.sync(() => { listingsEpoch = ++epochCounter; + everyFileRevisionEpoch = ++epochCounter; viewersByHost.clear(); - forgetEveryFileRevision(); }).pipe(Effect.andThen(Cache.invalidateAll(viewerFlights))); }; @@ -2954,12 +2935,6 @@ export const make = Effect.gen(function* () { Effect.sync(() => { bumpRefEpoch(input); listingsEpoch = ++epochCounter; - // Not keyed by epoch, so this one is dropped by hand. Merging or bringing a stale - // branch up to date moves the head, and a mark compared against what the head had - // before it moved reports a file as cleared that has been pushed to since. - forgetFileRevisions( - fileRevisionsScope(input.projectId, input.repository, input.number), - ); }), ), ); @@ -2969,10 +2944,6 @@ export const make = Effect.gen(function* () { const repository = yield* runAction(input); bumpRefEpoch({ ...input, repository }); listingsEpoch = ++epochCounter; - // Not keyed by epoch, so this one is dropped by hand. Merging or bringing a stale branch up - // to date moves the head, and a mark compared against what the head had before it moved - // reports a file as cleared that has been pushed to since. - forgetFileRevisions(fileRevisionsScope(input.projectId, repository, input.number)); if (input.action === "merge") { // A successful merge action can merely enqueue the PR or enable auto-merge. const confirmed = yield* summaryUncached({ ...input, repository }).pipe( From 3ed4814d3f4b8858a0b9f024dd9fbe473b75d836 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 17:04:44 -0400 Subject: [PATCH 42/78] refactor(server): the pull request service no longer branches on a provider's kind Signed-off-by: Yordis Prieto --- .../pullRequest/PullRequestService.test.ts | 154 +++++++++++++----- .../src/pullRequest/PullRequestService.ts | 36 ++-- 2 files changed, 127 insertions(+), 63 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 9a54e4731511..15622cd140ea 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -4730,58 +4730,134 @@ it.effect("finishes two presses on one file in the order they were made", () => }), ); +/** Azure, whose selector is a bare repository name, backed by this environment's own marks. */ +const azureViewedService = (projects: ReadonlyArray) => + makeService({ + projects, + providers: [ + fakeProvider("azure-devops", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + viewedFiles: "environment", + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getFilesViewed: () => Effect.die("the host keeps no marks of this environment's own"), + setFilesViewed: () => Effect.die("the host keeps no marks of this environment's own"), + getFileRevisions: (input) => + Effect.succeed({ revisions: new Map(input.paths.map((path) => [path, "blob-a"])) }), + }), + ], + }); + +const AZURE_PAIR = [ + project({ + id: "p1", + title: "platform web", + workspaceRoot: "/a", + repository: "acme/platform/_git/web", + provider: "azure-devops", + host: "dev.azure.com", + }), + project({ + id: "p2", + title: "other web", + workspaceRoot: "/b", + repository: "acme/other/_git/web", + provider: "azure-devops", + host: "dev.azure.com", + }), +]; + +const AZURE_PLATFORM = { projectId: "p1" as ProjectId, repository: "web", number: 1 }; +const AZURE_OTHER = { projectId: "p2" as ProjectId, repository: "web", number: 1 }; + +/** A project as an older environment recorded it, before this identity field was written. */ +const withoutIdentityField = ( + current: OrchestrationProjectShell, + field: "canonicalKey" | "displayName", +) => { + const { [field]: _dropped, ...identity } = current.repositoryIdentity!; + return { ...current, repositoryIdentity: identity } as unknown as OrchestrationProjectShell; +}; + it.effect("keeps the marks of two Azure repositories of the same name apart", () => Effect.gen(function* () { // Azure addresses a repository by its bare name, which is unique inside one of its projects // and not across an organisation. Two `web` repositories would otherwise share one row. + const service = yield* azureViewedService(AZURE_PAIR); + + yield* service.setFilesViewed({ + ...AZURE_PLATFORM, + files: [{ path: "src/a.ts", viewed: true }], + }); + + assert.deepStrictEqual((yield* service.filesViewed(AZURE_PLATFORM)).files, [ + { path: "src/a.ts", state: "viewed" }, + ]); + assert.deepStrictEqual((yield* service.filesViewed(AZURE_OTHER)).files, []); + }), +); + +it.effect("keeps two same-named Azure repositories apart without a canonical key", () => + Effect.gen(function* () { + // The path below the host is the only spelling left that tells the two apart. + const service = yield* azureViewedService( + AZURE_PAIR.map((current) => withoutIdentityField(current, "canonicalKey")), + ); + + yield* service.setFilesViewed({ + ...AZURE_PLATFORM, + files: [{ path: "src/a.ts", viewed: true }], + }); + + assert.deepStrictEqual((yield* service.filesViewed(AZURE_PLATFORM)).files, [ + { path: "src/a.ts", state: "viewed" }, + ]); + assert.deepStrictEqual((yield* service.filesViewed(AZURE_OTHER)).files, []); + }), +); + +it.effect("keeps a repository's marks when its identity carries only owner and name", () => + Effect.gen(function* () { + const current = project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }); + const stripped = withoutIdentityField( + withoutIdentityField(current, "canonicalKey"), + "displayName", + ); const service = yield* makeService({ projects: [ - project({ - id: "p1", - title: "platform web", - workspaceRoot: "/a", - repository: "acme/platform/_git/web", - provider: "azure-devops", - host: "dev.azure.com", - }), - project({ - id: "p2", - title: "other web", - workspaceRoot: "/b", - repository: "acme/other/_git/web", - provider: "azure-devops", - host: "dev.azure.com", - }), - ], - providers: [ - fakeProvider("azure-devops", { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - viewedFiles: "environment", - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, + { + ...stripped, + repositoryIdentity: { + ...stripped.repositoryIdentity, + owner: "group", + name: "project", }, - getFilesViewed: () => Effect.die("the host keeps no marks of this environment's own"), - setFilesViewed: () => Effect.die("the host keeps no marks of this environment's own"), - getFileRevisions: (input) => - Effect.succeed({ revisions: new Map(input.paths.map((path) => [path, "blob-a"])) }), - }), + } as unknown as OrchestrationProjectShell, ], + providers: [environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), [])], }); - const platform = { projectId: "p1" as ProjectId, repository: "web", number: 1 }; - const other = { projectId: "p2" as ProjectId, repository: "web", number: 1 }; - yield* service.setFilesViewed({ ...platform, files: [{ path: "src/a.ts", viewed: true }] }); + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); - assert.deepStrictEqual((yield* service.filesViewed(platform)).files, [ + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ { path: "src/a.ts", state: "viewed" }, ]); - assert.deepStrictEqual((yield* service.filesViewed(other)).files, []); }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index b52959b075d3..1c79d45893f7 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -273,6 +273,11 @@ interface SupportedProject { readonly repository: string; /** The host the repository lives on, which is the account boundary rather than the kind. */ readonly host: string; + /** + * The normalised remote, which is what this environment's own records are keyed by. Unique + * where `repository` is not: Azure's is a bare name that repeats across an organisation. + */ + readonly remote: string; } /** @@ -684,6 +689,9 @@ export const make = Effect.gen(function* () { api: withRateLimitBackoff(api, host, rateLimits), repository, host, + // Rungs for an identity missing its canonical key, the bare selector last because + // Azure's repeats across an organisation. + remote: identity.canonicalKey?.trim() || identity.displayName?.trim() || repository, }); } return { supported, unimplemented, viewerRoots }; @@ -1459,34 +1467,14 @@ export const make = Effect.gen(function* () { const runFork = Effect.runForkWith(context); /** - * Which repository a row belongs to, spelled widely enough that two of them are two rows. - * - * A provider's own selector is what the reads are addressed by, and Azure's is the bare - * repository name: unique inside one project and not across an organisation, so `api` in two - * projects would otherwise share one row and show each other's ticks. The remote already - * carries the whole path, so the marks are keyed by that instead. - */ - const filesViewedRepositoryOf = (project: SupportedProject) => { - if (project.api.kind !== "azure-devops") return project.repository; - const identity = project.project.repositoryIdentity; - const path = identity?.displayName?.trim(); - if (path !== undefined && path.length > 0) return path; - // The scope carries no project id, so the bare name Azure is addressed by would put two - // repositories called `api` on one row. The canonical remote repeats the host this scope - // already holds, and is the only other spelling that keeps the whole identity. - const canonical = identity?.canonicalKey?.trim(); - return canonical === undefined || canonical.length === 0 ? project.repository : canonical; - }; - - /** - * Which change request's marks, and whose. The host is part of it because the same - * `owner/repo` exists on more than one install, and the reader is part of it for the reason a + * Which change request's marks, and whose. Provider and host lead the table's key because the + * same repository exists on more than one install, and the reader is part of it for the reason a * host's own record is per-account. A host that names no reader is one reader, not none. */ const filesViewedScope = (project: SupportedProject, number: number, viewer: string | null) => ({ provider: project.api.kind, host: project.host, - repository: filesViewedRepositoryOf(project), + repository: project.remote, number, viewer: viewer ?? "", }); @@ -1733,7 +1721,7 @@ export const make = Effect.gen(function* () { // lookup and the insert lets two presses each find nothing, each make a gate of their own, // and neither wait on the other, which is the ordering this exists for. Effect.suspend(() => { - const key = `${project.project.id} ${filesViewedRepositoryOf(project).trim().toLowerCase()} ${number}`; + const key = `${project.project.id} ${project.remote} ${number}`; const held = filesViewedGates.get(key); const entry = held ?? { gate: Semaphore.makeUnsafe(1), pending: 0 }; if (held === undefined) filesViewedGates.set(key, entry); From 2c471a9cc76882dc584c00b7820f7a975fa97ee6 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 17:08:41 -0400 Subject: [PATCH 43/78] perf(server): an azure change request's files no longer wait on each other to be read Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestProvider.test.ts | 223 ++++++++++++++++++ .../AzureDevOpsPullRequestProvider.ts | 113 +++++---- 2 files changed, 288 insertions(+), 48 deletions(-) create mode 100644 apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts new file mode 100644 index 000000000000..c14c6cef6874 --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import { make } from "./AzureDevOpsPullRequestProvider.ts"; +import { MAX_DIFF_SLICE_BYTES } from "./azureDevOpsDiff.ts"; +import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; + +const ITERATION = { id: 3, headCommit: "head", mergeBaseCommit: "base" }; + +const PULL_REQUEST = { + number: 7, + title: "Pull request 7", + url: "https://dev.azure.com/acme/web/_git/web/pullrequest/7", + author: null, + headBranch: "feat/page", + baseBranch: "main", + state: "open" as const, + isDraft: false, + mergeability: "mergeable" as const, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + closedAt: null, + body: "", + reviewRequestLogins: [], + reviewers: [], + location: { project: "acme", repository: "web" }, + autoMergeEnabled: false, +}; + +function change( + path: string, + changeKind: AzureDevOpsChangeEntry["changeKind"] = "change", +): AzureDevOpsChangeEntry { + return { path, oldPath: path, changeKind, objectId: "8f80", originalObjectId: "0ca4" }; +} + +/** + * A file whose two sides share no line, so its patch is `lines` removals and `lines` additions of + * `width` characters each: the diff work and the patch bytes one file costs are both dialled from + * here, and they are what a slice is bounded by. + */ +function side(prefix: string, lines: number, width: number): string { + const pad = "z".repeat(width); + return `${Array.from({ length: lines }, (_, line) => `${prefix} ${line} ${pad}`).join("\n")}\n`; +} + +const readSlice = (input: { + readonly paths: ReadonlyArray; + readonly lines: number; + readonly width: number; + /** Paths the host refuses, which is one file's problem rather than the read's. */ + readonly refused?: ReadonlyArray; + /** Paths the change creates, so the host has nothing to hand back for their old side. */ + readonly created?: ReadonlyArray; + readonly cursor?: string; +}) => + Effect.gen(function* () { + const reads: string[] = []; + const refused = new Set(input.refused ?? []); + const created = new Set(input.created ?? []); + let inFlight = 0; + let peakInFlight = 0; + + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => Effect.succeed(PULL_REQUEST), + listIterations: () => Effect.succeed([ITERATION]), + listIterationChanges: () => + Effect.succeed({ + changes: input.paths.map((path) => + change(path, created.has(path) ? "new" : "change"), + ), + truncated: false, + }), + readItemContent: (item) => + Effect.gen(function* () { + reads.push(item.path); + inFlight += 1; + peakInFlight = Math.max(peakInFlight, inFlight); + // Every read suspends before it answers, as a subprocess would, so what runs at + // once is the scheduler's answer rather than an artefact of resolving inline. The + // later a file is listed the sooner it answers, to leave the assembled patch + // nothing but the change list to take its order from. + const answersAfter = input.paths.length - input.paths.indexOf(item.path); + for (let turn = 0; turn < answersAfter; turn += 1) yield* Effect.yieldNow; + inFlight -= 1; + if (refused.has(item.path)) { + return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({ + command: "az", + cwd: "/w", + operation: "readItemContent", + cause: "refused", + }); + } + const isOldSide = item.commit !== ITERATION.headCommit; + if (isOldSide && created.has(item.path)) return { contents: "", isBinary: false }; + return { + contents: side(isOldSide ? "old" : "new", input.lines, input.width), + isBinary: false, + }; + }), + }), + ), + ); + + const slice = yield* provider.getDiff({ + cwd: "/w", + repository: "acme/web", + host: "dev.azure.com", + number: 7, + ...(input.cursor === undefined ? {} : { cursor: input.cursor }), + }); + return { slice, reads, peakInFlight }; + }); + +/** Which files the patch carries a section for, in the order it carries them. */ +function patchedPaths(patch: string): ReadonlyArray { + return [...patch.matchAll(/^diff --git a\/(?\S+) b\//gmu)].map( + (match) => match.groups?.path ?? "", + ); +} + +describe("getDiff reads", () => { + it.effect("asks for both sides of several files at once rather than one side at a time", () => + Effect.gen(function* () { + // Each file is two `az` invocations, each paying a Python interpreter's start-up, so a + // slice read one side after another is most of what the Code tab waits for. + const read = yield* readSlice({ + paths: ["a.ts", "b.ts", "c.ts", "d.ts"], + lines: 2, + width: 4, + }); + + expect(read.peakInFlight).toBeGreaterThan(2); + }), + ); + + it.effect("holds the number of files it reads at once down", () => + Effect.gen(function* () { + // The host throttles, and `az` is a process on the same machine the reader runs agents on, + // so a long change is read in batches rather than all at once. + const paths = Array.from({ length: 24 }, (_, file) => `file-${file}.ts`); + const read = yield* readSlice({ paths, lines: 2, width: 4 }); + + expect(read.reads).toHaveLength(paths.length * 2); + expect(read.peakInFlight).toBeLessThanOrEqual(8); + }), + ); + + it.effect("keeps the patch in the order the change was listed, whoever answered first", () => + Effect.gen(function* () { + const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"]; + const read = yield* readSlice({ paths, lines: 2, width: 4 }); + + expect(patchedPaths(read.slice.patch)).toEqual(paths); + expect(read.slice.nextCursor).toBeNull(); + }), + ); + + it.effect("leaves a file the host refused listed without its hunks, in its place", () => + Effect.gen(function* () { + const paths = ["a.ts", "b.ts", "c.ts"]; + const read = yield* readSlice({ paths, lines: 2, width: 4, refused: ["b.ts"] }); + + expect(patchedPaths(read.slice.patch)).toEqual(paths); + expect(read.slice.truncated).toBe(true); + // Its section ends at its header, and the files around it still carry their hunks. + expect(read.slice.patch).toContain("+++ b/b.ts\ndiff --git a/c.ts"); + expect(read.slice.patch.match(/^@@ /gmu)).toHaveLength(2); + }), + ); +}); + +describe("what one diff slice spends", () => { + it.effect("stops on the byte ceiling without carrying what it read past it", () => + Effect.gen(function* () { + // Two of these fill the slice, and the batch they were read in reached two files further. + // Those two belong to the next slice: carrying them would put the request past a ceiling + // that is there to bound what one answer weighs. + const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; + const read = yield* readSlice({ paths, lines: 100, width: 900 }); + + expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES); + expect(patchedPaths(read.slice.patch)).toEqual(["a.ts", "b.ts"]); + expect(read.slice.nextCursor).toBe(`${ITERATION.id}:2`); + expect(new Set(read.reads)).toEqual(new Set(["a.ts", "b.ts", "c.ts", "d.ts"])); + }), + ); + + it.effect("carries a whole new file and stops the slice on what it weighed", () => + Effect.gen(function* () { + // A creation has no edit distance to search out, so no edit bound applies to it and its + // section is the whole file. What keeps a run of them from filling one answer is the bytes + // they weighed, which the slice has to be charged for. + const paths = ["new.ts", "b.ts", "c.ts", "d.ts"]; + const read = yield* readSlice({ paths, lines: 8_000, width: 30, created: ["new.ts"] }); + + expect(patchedPaths(read.slice.patch)).toEqual(["new.ts"]); + expect(read.slice.patch).toContain("--- /dev/null"); + expect(read.slice.patch).toContain("@@ -0,0 +1,8000 @@"); + expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES); + expect(read.slice.nextCursor).toBe(`${ITERATION.id}:1`); + }), + ); + + it.effect("carries on from where the last slice stopped", () => + Effect.gen(function* () { + const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; + const read = yield* readSlice({ + paths, + lines: 100, + width: 900, + cursor: `${ITERATION.id}:2`, + }); + + expect(patchedPaths(read.slice.patch)).toEqual(["c.ts", "d.ts"]); + expect(read.slice.nextCursor).toBe(`${ITERATION.id}:4`); + }), + ); +}); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index cdd4dfd77f7e..13f2208adb37 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -30,6 +30,14 @@ import type { AzureDevOpsRepositoryLocation, } from "./azureDevOpsPullRequestJson.ts"; +/** + * How many of a slice's files are read at once. Every file is two `az` invocations, each paying a + * Python interpreter's start-up, so reading them one after another is most of what the Code tab + * waits for. Four files means eight processes at once: the fan-out the GitHub CLI reads its + * per-file stats with here, and low enough not to swamp the host's throttling or the machine. + */ +const DIFF_FILE_CONCURRENCY = 4; + const CAPABILITIES: PullRequestCapabilities = { // Azure serves no patch of its own, so the one the Code tab reads is built here out of the // files an iteration changed and both sides of each of them. @@ -200,8 +208,10 @@ export const make = Effect.gen(function* () { const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false }; /** - * Both sides of one changed file. Only the sides a change actually has are asked for: Azure - * answers for a file that is not at a commit with a failure rather than with nothing. + * Both sides of one changed file, read at once because neither answer depends on the other and + * `az` pays a Python interpreter's start-up for each. Only the sides a change actually has are + * asked for: Azure answers for a file that is not at a commit with a failure rather than with + * nothing. */ const readTexts = (input: { readonly cwd: string; @@ -209,34 +219,35 @@ export const make = Effect.gen(function* () { readonly iteration: AzureDevOpsIteration; readonly change: Pick; }) => - Effect.gen(function* () { - const oldItem = + Effect.all( + [ input.change.changeKind === "new" - ? EMPTY_ITEM - : yield* cli.readItemContent({ + ? Effect.succeed(EMPTY_ITEM) + : cli.readItemContent({ cwd: input.cwd, location: input.location, path: input.change.oldPath, commit: input.iteration.mergeBaseCommit, - }); - const newItem = + }), input.change.changeKind === "deleted" - ? EMPTY_ITEM - : yield* cli.readItemContent({ + ? Effect.succeed(EMPTY_ITEM) + : cli.readItemContent({ cwd: input.cwd, location: input.location, path: input.change.path, commit: input.iteration.headCommit, - }); - const texts: AzureDevOpsFileTexts = { + }), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([oldItem, newItem]): AzureDevOpsFileTexts => ({ oldContents: oldItem.contents, newContents: newItem.contents, // Azure hands a file it calls binary over in an encoding of its own, so its own word on // that is taken rather than looked for in bytes it may never have sent verbatim. binary: oldItem.isBinary || newItem.isBinary, - }; - return texts; - }); + })), + ); /** * What the whole pull request changed, taken from its latest push. An iteration's changes are @@ -390,40 +401,46 @@ export const make = Effect.gen(function* () { let truncated = listed.truncated; let bytes = 0; let index = cursor?.fileIndex ?? 0; - while (index < changes.length) { - const change = changes.at(index); - if (change === undefined) break; - // One file per pair of reads, and a pair Azure refuses is one file rather than the - // whole slice: an oversize blob or a path `az` will not carry through leaves that file - // listed without its hunks, and everything around it still renders. - const texts = yield* readTexts({ - cwd: input.cwd, - location: scope.location, - iteration, - change, - }).pipe( - // Only what is this one file's problem. A signed-out CLI, a rate limit or no `az` at - // all is the read failing rather than the file, and belongs to the caller, which - // pauses the host rather than showing every file in the change as unreadable. - Effect.catchTags({ - AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null), - AzureDevOpsCommandFailedError: () => Effect.succeed(null), - AzureDevOpsPullRequestReadError: () => Effect.succeed(null), - }), + let full = false; + while (!full && index < changes.length) { + const batch = changes.slice(index, index + DIFF_FILE_CONCURRENCY); + const read = yield* Effect.forEach( + batch, + (change) => + // A pair Azure refuses is one file rather than the whole slice: an oversize blob or + // a path `az` will not carry through leaves that file listed without its hunks, and + // everything around it still renders. + readTexts({ cwd: input.cwd, location: scope.location, iteration, change }).pipe( + // Only what is this one file's problem. A signed-out CLI, a rate limit or no `az` + // at all is the read failing rather than the file, and belongs to the caller, + // which pauses the host rather than showing every file as unreadable. + Effect.catchTags({ + AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null), + AzureDevOpsCommandFailedError: () => Effect.succeed(null), + AzureDevOpsPullRequestReadError: () => Effect.succeed(null), + }), + Effect.map((texts) => ({ change, texts })), + ), + { concurrency: DIFF_FILE_CONCURRENCY }, ); - const file = - texts === null - ? azureDevOpsUnreadableFilePatch(change) - : azureDevOpsFilePatch({ change, texts, timeoutMillis: MAX_FILE_DIFF_MILLIS }); - sections.push(file.section); - bytes += byteLength(file.section); - truncated = truncated || file.truncated; - index += 1; - // A file whose diff was given up on spent the whole of what one file is allowed and has - // a header to show for it, so the byte budget would let a change full of them spend that - // over and over in the one request. The slice ends there instead, and reading on picks - // up at the file behind it. - if (bytes >= MAX_DIFF_SLICE_BYTES || file.abandoned) break; + for (const { change, texts } of read) { + const file = + texts === null + ? azureDevOpsUnreadableFilePatch(change) + : azureDevOpsFilePatch({ change, texts, timeoutMillis: MAX_FILE_DIFF_MILLIS }); + sections.push(file.section); + bytes += byteLength(file.section); + truncated = truncated || file.truncated; + index += 1; + // A file whose diff was given up on spent the whole of what one file is allowed and + // has a header to show for it, so the byte budget would let a change full of them + // spend that over and over in the one request. What the batch read past the point the + // slice filled is left for the next one rather than carried into this answer. + if (bytes >= MAX_DIFF_SLICE_BYTES || file.abandoned) { + full = true; + break; + } + } } const slice: ProviderDiffSlice = { From f4845a6d566de3cc31ed0d80fb09955d271ae11f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 17:18:20 -0400 Subject: [PATCH 44/78] fix(server): reading an azure diff no longer holds every other client on the server Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestProvider.test.ts | 19 +- .../AzureDevOpsPullRequestProvider.ts | 24 ++- .../src/pullRequest/azureDevOpsDiff.test.ts | 166 ++++++++++++++++-- .../server/src/pullRequest/azureDevOpsDiff.ts | 144 +++++++++++++-- 4 files changed, 317 insertions(+), 36 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts index c14c6cef6874..5d9cd33839a4 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import { make } from "./AzureDevOpsPullRequestProvider.ts"; -import { MAX_DIFF_SLICE_BYTES } from "./azureDevOpsDiff.ts"; +import { MAX_DIFF_SLICE_BYTES, MAX_FILE_DIFF_EDITS } from "./azureDevOpsDiff.ts"; import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; const ITERATION = { id: 3, headCommit: "head", mergeBaseCommit: "base" }; @@ -39,7 +39,8 @@ function change( /** * A file whose two sides share no line, so its patch is `lines` removals and `lines` additions of * `width` characters each: the diff work and the patch bytes one file costs are both dialled from - * here, and they are what a slice is bounded by. + * here, and they are what a slice is bounded by. Each line carries its own number and a prefix + * as well, so `width` is a floor on how long a line is rather than its byte count. */ function side(prefix: string, lines: number, width: number): string { const pad = "z".repeat(width); @@ -190,6 +191,20 @@ describe("what one diff slice spends", () => { }), ); + it.effect("stops once the diff work one request may do is spent", () => + Effect.gen(function* () { + // Short lines are cheap on the wire and dear to diff, so the byte ceiling alone would let + // one request hold the thread through a dozen of them. Each of these is half of what one + // file is allowed, and the slice ends while there is still room for another. + const paths = Array.from({ length: 12 }, (_, file) => `file-${file}.ts`); + const read = yield* readSlice({ paths, lines: MAX_FILE_DIFF_EDITS / 4, width: 1 }); + + expect(read.slice.patch.length).toBeLessThan(MAX_DIFF_SLICE_BYTES); + expect(patchedPaths(read.slice.patch)).toEqual(paths.slice(0, 5)); + expect(read.slice.nextCursor).toBe(`${ITERATION.id}:5`); + }), + ); + it.effect("carries a whole new file and stops the slice on what it weighed", () => Effect.gen(function* () { // A creation has no edit distance to search out, so no edit bound applies to it and its diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 13f2208adb37..e0fa5145a105 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -8,8 +8,9 @@ import { formatAzureDevOpsDiffCursor, parseAzureDevOpsDiffCursor, MAX_DIFF_SLICE_BYTES, + MAX_DIFF_SLICE_EDITS, byteLength, - MAX_FILE_DIFF_MILLIS, + MAX_FILE_DIFF_EDITS, type AzureDevOpsFileTexts, } from "./azureDevOpsDiff.ts"; import { @@ -400,6 +401,7 @@ export const make = Effect.gen(function* () { const sections: string[] = []; let truncated = listed.truncated; let bytes = 0; + let edits = 0; let index = cursor?.fileIndex ?? 0; let full = false; while (!full && index < changes.length) { @@ -424,19 +426,29 @@ export const make = Effect.gen(function* () { { concurrency: DIFF_FILE_CONCURRENCY }, ); for (const { change, texts } of read) { + // The diff is synchronous and the reads no longer stand between one file and the next + // to let anything else on the server run, so the thread is handed back here. + yield* Effect.yieldNow; const file = texts === null ? azureDevOpsUnreadableFilePatch(change) - : azureDevOpsFilePatch({ change, texts, timeoutMillis: MAX_FILE_DIFF_MILLIS }); + : azureDevOpsFilePatch({ change, texts }); sections.push(file.section); bytes += byteLength(file.section); + edits += file.edits; truncated = truncated || file.truncated; index += 1; // A file whose diff was given up on spent the whole of what one file is allowed and - // has a header to show for it, so the byte budget would let a change full of them - // spend that over and over in the one request. What the batch read past the point the - // slice filled is left for the next one rather than carried into this answer. - if (bytes >= MAX_DIFF_SLICE_BYTES || file.abandoned) { + // has only a header to show for it, so the byte budget alone would let a change full + // of them spend that over and over in one request. Checked after the file is added + // rather than before it, so every slice carries at least one: a section heavier than + // the whole budget would otherwise never be added, and the read would answer the same + // slice forever without moving the cursor. + if ( + bytes >= MAX_DIFF_SLICE_BYTES || + edits + MAX_FILE_DIFF_EDITS > MAX_DIFF_SLICE_EDITS || + file.abandoned + ) { full = true; break; } diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts index 3d6ab8d39830..c111d979da39 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from "vite-plus/test"; import { azureDevOpsFilePatch, azureDevOpsUnreadableFilePatch, + byteLength, formatAzureDevOpsDiffCursor, + MAX_FILE_DIFF_EDITS, parseAzureDevOpsDiffCursor, } from "./azureDevOpsDiff.ts"; import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; @@ -150,21 +152,144 @@ describe("azureDevOpsFilePatch", () => { ); }); - it("gives up on a file whose two sides are too far apart to diff in the time allowed", () => { - // The line diff costs the product of the two sides, so a pair under the size ceiling that - // shares nothing still runs long. Left to itself it would hold the server for as long as it - // took; here it is given a millisecond so the giving up is the thing being read. - const oldContents = Array.from({ length: 3_000 }, (_, line) => `old ${line}`).join("\n"); - const newContents = Array.from({ length: 3_000 }, (_, line) => `new ${line}`).join("\n"); + const lineRange = (count: number, prefix: string) => + Array.from({ length: count }, (_, line) => `${prefix} ${line}`).join("\n"); + + it("lists a file too far apart to diff as wholly replaced", () => { + // Sharing no line at all costs one edit per line on each side, so this pair is twice the + // ceiling apart. Left to itself the search costs about the square of that and would hold the + // whole server, every websocket client with it, while it worked out a patch nobody reads. What + // the two sides are is known without any search, so the reader gets them. + const lines = (prefix: string) => + Array.from({ length: MAX_FILE_DIFF_EDITS }, (_, line) => `${prefix} ${line}`).join("\n"); const patch = azureDevOpsFilePatch({ change: change({ path: "generated.ts", oldPath: "generated.ts" }), - texts: texts(oldContents, newContents), - timeoutMillis: 1, + texts: texts(`${lines("old")}\n`, `${lines("new")}\n`), }); expect(patch.truncated).toBe(true); - // And it says so, because the reader of a run of files is meant to stop rather than spend - // that time again on each of the ones behind it. + // And it says the search was given up on, because the reader of a run of files is meant to + // stop rather than spend that work again on each of the ones behind it. + expect(patch.abandoned).toBe(true); + expect(patch.section).toContain(`@@ -1,${MAX_FILE_DIFF_EDITS} +1,${MAX_FILE_DIFF_EDITS} @@`); + expect(patch.section.match(/^-old /gmu)).toHaveLength(MAX_FILE_DIFF_EDITS); + expect(patch.section.match(/^\+new /gmu)).toHaveLength(MAX_FILE_DIFF_EDITS); + }); + + it("writes out a wholly new file however many lines it has", () => { + // Nothing on the old side means there was no edit distance to search out, so this is the + // minimal patch and not a stand-in for one. Fifteen thousand lines of thirty bytes is the + // shape this used to lose: inside the byte ceiling that gates every file, and well past every + // bound a two-sided file answers to, none of which is protecting against anything here. + const contents = `${Array.from({ length: 15_000 }, () => "x".repeat(29)).join("\n")}\n`; + const patch = azureDevOpsFilePatch({ + change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }), + texts: texts("", contents), + }); + + expect(byteLength(contents)).toBeLessThan(512 * 1024); + expect(patch.truncated).toBe(false); + expect(patch.abandoned).toBe(false); + expect(patch.edits).toBe(15_000); + expect(patch.section).toContain("@@ -0,0 +1,15000 @@"); + // Only the `+++` of the header on top of the file's own lines, so nothing was dropped out of + // the middle. + expect(patch.section.match(/^\+/gmu)).toHaveLength(15_001); + }); + + it("writes out a wholly new file whose patch weighs more than one file is let through at", () => { + // Every line carries a prefix, so a side of very short lines answers with up to twice its own + // bytes. A two-sided file declines to be written out at that size, because there was a real + // diff it was only standing in for. A creation has no smaller true patch to fall back to, and + // the byte ceiling on each side is what bounds it instead. + const contents = `${Array.from({ length: 200_000 }, () => "x").join("\n")}\n`; + const patch = azureDevOpsFilePatch({ + change: change({ path: "bundle.min.js", oldPath: "bundle.min.js", changeKind: "new" }), + texts: texts("", contents), + }); + + expect(byteLength(contents)).toBeLessThan(512 * 1024); + expect(byteLength(patch.section)).toBeGreaterThan(512 * 1024); + expect(patch.truncated).toBe(false); + expect(patch.edits).toBe(200_000); + expect(patch.section).toContain("@@ -0,0 +1,200000 @@"); + }); + + it("writes out a wholly deleted file however many lines it had", () => { + const contents = `${lineRange(20_000, "line")}\n`; + const patch = azureDevOpsFilePatch({ + change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }), + texts: texts(contents, ""), + }); + + expect(patch.truncated).toBe(false); + expect(patch.abandoned).toBe(false); + expect(patch.edits).toBe(20_000); + expect(patch.section).toContain("@@ -1,20000 +0,0 @@"); + expect(patch.section.match(/^-line /gmu)).toHaveLength(20_000); + expect(patch.section.match(/^-/gmu)).toHaveLength(20_001); + }); + + it("gives an empty new file no hunk to read", () => { + // A file with nothing on either side has no lines to claim were replaced, and git writes it + // as a header alone. + const patch = azureDevOpsFilePatch({ + change: change({ path: "EMPTY.md", oldPath: "EMPTY.md", changeKind: "new" }), + texts: texts("", ""), + }); + + expect(patch.edits).toBe(0); + expect(patch.section).not.toContain("@@"); + }); + + it("marks a replaced side that does not end in a newline", () => { + const lines = (prefix: string) => + Array.from({ length: MAX_FILE_DIFF_EDITS }, (_, line) => `${prefix} ${line}`).join("\n"); + const patch = azureDevOpsFilePatch({ + change: change(), + texts: texts(`${lines("old")}\n`, lines("new")), + }); + + expect(patch.abandoned).toBe(true); + expect(patch.section.match(/^\\ No newline at end of file$/gmu)).toHaveLength(1); + expect(patch.section).toContain( + `+new ${MAX_FILE_DIFF_EDITS - 1}\n\\ No newline at end of file\n`, + ); + }); + + it("keeps a file too long to call wholly replaced listed without its hunks", () => { + // Past a few thousand lines, being further apart than the ceiling no longer means the sides + // share little: the file may have changed in one corner, and calling it wholly replaced would + // bury that corner in a wall of red and green. + const lines = (prefix: string) => + Array.from({ length: 5_000 }, (_, line) => `${prefix} ${line}`).join("\n"); + const patch = azureDevOpsFilePatch({ + change: change({ path: "generated.ts", oldPath: "generated.ts" }), + texts: texts(`${lines("old")}\n`, `${lines("new")}\n`), + }); + + expect(patch.abandoned).toBe(true); + expect(patch.section).toBe( + [ + "diff --git a/generated.ts b/generated.ts", + "--- a/generated.ts", + "+++ b/generated.ts", + "", + ].join("\n"), + ); + }); + + it("keeps a replacement heavier than one file's bytes listed without its hunks", () => { + // Few enough lines to be worth calling wholly replaced, and long enough lines that saying so + // would answer with twice what either side was let through at. + const wide = "z".repeat(200); + const lines = (prefix: string) => + Array.from({ length: 2_000 }, (_, line) => `${prefix} ${line} ${wide}`).join("\n"); + const patch = azureDevOpsFilePatch({ + change: change({ path: "generated.ts", oldPath: "generated.ts" }), + texts: texts(`${lines("old")}\n`, `${lines("new")}\n`), + }); + expect(patch.abandoned).toBe(true); expect(patch.section).toBe( [ @@ -185,6 +310,27 @@ describe("azureDevOpsFilePatch", () => { expect(patch.abandoned).toBe(false); }); + it("counts what the diff worked out, which is what the file cost to diff", () => { + // The caller spends a budget of these across a slice, so they have to be the edits the search + // actually made: one line replaced is a removal and an addition, and the three lines of + // context around them cost nothing. + const patch = azureDevOpsFilePatch({ + change: change(), + texts: texts("one\ntwo\nthree\nfour\n", "one\ntwo again\nthree\nfour\n"), + }); + + expect(patch.edits).toBe(2); + }); + + it("counts nothing for a file it never diffed", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "logo.png", oldPath: "logo.png" }), + texts: texts("PNG\u0000old", "PNG\u0000new"), + }); + + expect(patch.edits).toBe(0); + }); + it("marks a file that does not end in a newline, as git does", () => { const patch = azureDevOpsFilePatch({ change: change(), diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts index f8a451ae483f..3d5fe231f08a 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -63,6 +63,12 @@ export interface AzureDevOpsFilePatch { * is meant to stop here rather than pay that again for each of the ones behind it. */ readonly abandoned: boolean; + /** + * Lines the diff added or removed, which is the edit distance it had to search out and so what + * the file cost the thread it ran on. The caller reading a run of files spends a budget of these + * rather than of bytes: a file of short lines is cheap on the wire and dear to diff. + */ + readonly edits: number; } /** @@ -76,12 +82,39 @@ const MAX_FILE_BYTES = 512 * 1024; const PATCH_CONTEXT_LINES = 3; /** - * How long one file may be diffed for. The line diff costs the product of the two sides, so a pair - * of files under the size ceiling that share almost nothing can still hold the whole server for a - * long time. Past this the file is listed without its hunks, which is what the size ceiling already - * does and what the reader is already shown a sign of. + * How far apart one file's two sides may be before it is listed without its hunks. The line diff + * searches for the edit distance and costs about the square of it, so a pair of files under the + * size ceiling that share almost nothing would otherwise hold the whole server, and every websocket + * client with it, while it works out a patch of tens of thousands of lines nobody reads. Bounded in + * edits rather than in milliseconds so a change slices the same way on every machine. + * + * Measured at around 210ms for a pair at the size ceiling that shares no line at all, which is the + * longest this can hold the thread for one file. */ -export const MAX_FILE_DIFF_MILLIS = 2_000; +export const MAX_FILE_DIFF_EDITS = 2_000; + +/** + * How many lines a file may be listed as wholly replaced by when its diff was given up on. The + * edit ceiling is a distance rather than a proportion, so a long file can exceed it having changed + * in one corner only, and calling that a whole replacement would be a wall of red and green hiding + * the part that moved. Four times the ceiling keeps the claim within reach of what is known to + * differ: measured against this repository's own history, no section it admits overstates the real + * change by more than about a factor of two. + */ +const MAX_FULL_REPLACEMENT_LINES = 4 * MAX_FILE_DIFF_EDITS; + +/** + * A backstop for a machine slower than the one the edit ceiling was measured on. Nothing within + * that ceiling comes near this on ordinary hardware, so it changes no patch; it is only here so + * the longest one file can hold the thread stays a number rather than a hope. + */ +const MAX_FILE_DIFF_MILLIS = 500; + +/** + * How much diff work one slice does before the rest is left for the next one, which bounds what a + * single request can cost the thread at this and one more file's worth. + */ +export const MAX_DIFF_SLICE_EDITS = 6_000; /** * How much patch one slice carries before the rest is left for the next one. Every file costs a @@ -90,6 +123,17 @@ export const MAX_FILE_DIFF_MILLIS = 2_000; */ export const MAX_DIFF_SLICE_BYTES = 256 * 1024; +/** Git's own note for a side whose last line has no newline after it. */ +const NO_NEWLINE_MARKER = "\\ No newline at end of file"; + +/** A text's lines, without the empty one that a trailing newline leaves behind a split. */ +function contentLines(contents: string): ReadonlyArray { + if (contents === "") return []; + const lines = contents.split("\n"); + if (lines.at(-1) === "") lines.pop(); + return lines; +} + /** A NUL byte is git's own test for it, and it survives Azure's JSON envelope intact. */ function isBinary(contents: string): boolean { return contents.includes("\u0000"); @@ -129,6 +173,40 @@ function patchHeader(change: AzureDevOpsChangeEntry): string { return lines.join("\n"); } +/** + * A file written out as wholly replaced: every old line gone, every new line arrived, in one hunk. + * Costs no search at all, around 45ns a line, so it is both the whole patch for a file that has + * only one side and a stand-in for one whose real diff was given up on. + */ +function replacementSection(header: string, texts: AzureDevOpsFileTexts): string { + const oldLines = contentLines(texts.oldContents); + const newLines = contentLines(texts.newContents); + const noNewline = (contents: string, lines: ReadonlyArray) => + lines.length > 0 && !contents.endsWith("\n") ? [NO_NEWLINE_MARKER] : []; + return [ + header, + `@@ -${hunkRange(1, oldLines.length)} +${hunkRange(1, newLines.length)} @@`, + ...oldLines.map((line) => `-${line}`), + ...noNewline(texts.oldContents, oldLines), + ...newLines.map((line) => `+${line}`), + ...noNewline(texts.newContents, newLines), + "", + ].join("\n"); +} + +/** + * The same section, for a file that has two sides and so a real diff that this is only standing in + * for. Null where the claim would be too loose to make or too heavy to send, leaving the file + * listed without its hunks. + */ +function boundedReplacementSection(header: string, texts: AzureDevOpsFileTexts): string | null { + const lines = contentLines(texts.oldContents).length + contentLines(texts.newContents).length; + if (lines > MAX_FULL_REPLACEMENT_LINES) return null; + const section = replacementSection(header, texts); + // One file's worth of bytes, the same ceiling its two sides were each let through under. + return byteLength(section) > MAX_FILE_BYTES ? null : section; +} + /** * One file's section of a unified patch, built here because Azure has no route that carries one: * its diff routes name the files that changed and their blob ids, and the contents are a separate @@ -137,8 +215,6 @@ function patchHeader(change: AzureDevOpsChangeEntry): string { export function azureDevOpsFilePatch(input: { readonly change: AzureDevOpsChangeEntry; readonly texts: AzureDevOpsFileTexts; - /** How long this one file may be diffed for, at most what any file is allowed. */ - readonly timeoutMillis?: number; }): AzureDevOpsFilePatch { const header = patchHeader(input.change); const { oldContents, newContents } = input.texts; @@ -146,10 +222,26 @@ export function azureDevOpsFilePatch(input: { if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) { // Git's own wording for a file it will not spell out, which every diff viewer already reads. const binary = `Binary files a/${input.change.oldPath} and b/${input.change.path} differ`; - return { section: `${header}\n${binary}\n`, truncated: true, abandoned: false }; + return { section: `${header}\n${binary}\n`, truncated: true, abandoned: false, edits: 0 }; } if (byteLength(oldContents) > MAX_FILE_BYTES || byteLength(newContents) > MAX_FILE_BYTES) { - return { section: `${header}\n`, truncated: true, abandoned: false }; + return { section: `${header}\n`, truncated: true, abandoned: false, edits: 0 }; + } + + // Nothing on one side is a creation or a deletion, where the whole file is the change and there + // is no edit distance to search out: writing both sides is the minimal patch, and it is linear + // rather than quadratic in the file's length. The edit ceiling has nothing to protect against + // here, and applying it would abandon a large new file after doing no work worth saving. + const created = oldContents === "" && newContents !== ""; + const deleted = newContents === "" && oldContents !== ""; + if (created || deleted) { + const lines = contentLines(created ? newContents : oldContents); + return { + section: replacementSection(header, input.texts), + truncated: false, + abandoned: false, + edits: lines.length, + }; } const patch = structuredPatch( @@ -161,25 +253,41 @@ export function azureDevOpsFilePatch(input: { undefined, { context: PATCH_CONTEXT_LINES, - timeout: Math.min(input.timeoutMillis ?? MAX_FILE_DIFF_MILLIS, MAX_FILE_DIFF_MILLIS), + maxEditLength: MAX_FILE_DIFF_EDITS, + timeout: MAX_FILE_DIFF_MILLIS, }, ); - // The bound is reported by giving nothing back, and a file whose diff was given up on is a file - // listed without its hunks rather than a file dropped from the change. - if (patch === undefined) return { section: `${header}\n`, truncated: true, abandoned: true }; + // The bound is reported by giving nothing back. Such a file is listed as wholly replaced where + // that is close enough to the truth to say, and listed without its hunks otherwise, rather than + // dropped from the change. Either way it spent the whole of what one file is allowed to get here, + // which is what `edits` carries: writing the replacement out costs nothing on top. + if (patch === undefined) { + const replaced = boundedReplacementSection(header, input.texts); + return { + section: replaced ?? `${header}\n`, + truncated: true, + abandoned: true, + edits: MAX_FILE_DIFF_EDITS, + }; + } - const hunks = patch.hunks.map((hunk) => - [ + let edits = 0; + const hunks = patch.hunks.map((hunk) => { + for (const line of hunk.lines) { + if (line.startsWith("+") || line.startsWith("-")) edits += 1; + } + return [ `@@ -${hunkRange(hunk.oldStart, hunk.oldLines)} +${hunkRange(hunk.newStart, hunk.newLines)} @@`, ...hunk.lines, - ].join("\n"), - ); + ].join("\n"); + }); // A pure rename has no hunks to give. It is still listed, because dropping it would take the // file out of the change altogether. return { section: hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`, truncated: false, abandoned: false, + edits, }; } @@ -191,5 +299,5 @@ export function azureDevOpsFilePatch(input: { export function azureDevOpsUnreadableFilePatch( change: AzureDevOpsChangeEntry, ): AzureDevOpsFilePatch { - return { section: `${patchHeader(change)}\n`, truncated: true, abandoned: false }; + return { section: `${patchHeader(change)}\n`, truncated: true, abandoned: false, edits: 0 }; } From 00fa35832cfe619c23fa78537a2fb2ccdc0aabce Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 17:28:37 -0400 Subject: [PATCH 45/78] docs: pull request comments carry the trap rather than restate the code Signed-off-by: Yordis Prieto --- .../pullRequest/AzureDevOpsPullRequestCli.ts | 2 +- .../pullRequest/BitbucketPullRequestApi.ts | 24 ++----- .../src/pullRequest/GitHubPullRequestCli.ts | 10 +-- .../src/pullRequest/PullRequestProvider.ts | 19 +++--- .../src/pullRequest/PullRequestService.ts | 62 ++++++++----------- .../src/pullRequest/bitbucketDiffRevisions.ts | 6 +- .../pullRequest/PullRequestCodeTab.tsx | 21 +++---- .../pullRequest/usePullRequestFilesViewed.ts | 26 +++----- packages/contracts/src/pullRequest.ts | 37 ++++------- 9 files changed, 69 insertions(+), 138 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 3255124f9052..150bfa7bb19c 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -667,7 +667,7 @@ export const make = Effect.gen(function* () { `versionDescriptor.version=${input.commit}`, "includeContent=true", // Azure leaves `contentMetadata` out unless this is asked for, and with it goes its own - // word on whether the file is binary — which is the only reliable one, since a binary + // word on whether the file is binary, which is the only reliable one, since a binary // file arrives encoded rather than as the bytes it is on the host. "includeContentMetadata=true", // Without this Azure answers with the file's own bytes rather than with a JSON diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index 6e1fe8ae52d6..3cbbd1a1833f 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -140,12 +140,9 @@ const CONVERSATION_PAGES = 10; /** The same ceiling the gh and glab diff reads use. */ const DIFF_MAX_BYTES = 8 * 1024 * 1024; /** - * How long one read of a pull request's patch keeps answering the version reads behind it, and how - * many pull requests are held that way at once. - * * A reader ticking files off names one new path at a time, and a path the caller has not asked - * about before is a path it cannot answer from what it holds, so without this every tick pays for - * the whole patch again. Deliberately far shorter than the window the caller holds versions for: + * about before cannot be answered from what it holds, so without this every tick pays for the + * whole patch again. Deliberately far shorter than the window the caller holds versions for: * a refresh drops what the caller holds precisely so the next read reaches Bitbucket, and this * must not be what answers it instead. */ @@ -203,11 +200,6 @@ export class BitbucketPullRequestApi extends Context.Service< * Read off the pull request's own patch, the only place Bitbucket states a file's version. A * path the patch does not carry is answered as the empty revision, and left out altogether * when the patch was cut short at the byte ceiling and so cannot be spoken for. - * - * The versions themselves are held by the caller rather than here: the marks and the badge - * they feed share one window, and a second one underneath it would keep answering after a - * refresh had asked it not to. The patch they are read out of is held for a few seconds, which - * is what keeps a reader ticking one file after another from downloading it once per tick. */ readonly getFileRevisions: (input: { readonly repository: string; @@ -586,11 +578,7 @@ export const make = Effect.gen(function* () { ), ); - /** - * The pull request's whole patch, shared by the version reads that come one tick at a time. A - * second tick arriving while the first read is still in flight waits on that read rather than - * starting another. - */ + /** The pull request's whole patch, shared by the version reads that come one tick at a time. */ const revisionPatches = yield* Cache.makeWith( (key: string) => { const [repository, number] = JSON.parse(key) as [string, number]; @@ -683,12 +671,8 @@ export const make = Effect.gen(function* () { : Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe( Effect.map((diff) => { const all = parseDiffFileRevisions(diff.patch); - // Narrowed to what was asked for rather than handed back whole: the caller compares - // the paths it named, and a patch of a thousand files has no business in its answer. - // // A patch cut short at the byte ceiling says nothing about the files past the cut, - // so those paths are left out rather than reported as removed: the caller reads an - // absent path as one it could not learn about, and a mark on it is left alone. + // so those paths are left out rather than reported as removed. const asked = new Map(); for (const path of input.paths) { const revision = all.get(path); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 7123c6068bf3..33ebe73ba4ab 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -560,8 +560,7 @@ export class GitHubPullRequestCli extends Context.Service< /** * Which files of the pull request the signed-in account has cleared, and which of those have - * been pushed to since. Read apart from the patch because GitHub only reports it over GraphQL, - * and because the two answers go stale at completely different rates. + * been pushed to since. Read apart from the patch because GitHub only reports it over GraphQL. */ readonly getPullRequestFilesViewed: (input: { readonly cwd: string; @@ -1030,9 +1029,8 @@ export const make = Effect.gen(function* () { * addressed by: a reaction on its description, or a rewrite of its words. * * A pull request keeps its node id for life, so it is remembered rather than re-read: a reader - * ticking files viewed would otherwise pay a GraphQL round trip per press. Bounded and - * oldest-first, since a long-lived server sees far more pull requests than a reader ever has - * open. + * ticking files viewed would otherwise pay a GraphQL round trip per press. Bounded, since a + * long-lived server sees far more pull requests than a reader ever has open. */ const NODE_ID_CACHE_CAPACITY = 128; const nodeIds = new Map(); @@ -2311,8 +2309,6 @@ export const make = Effect.gen(function* () { if (page.nextCursor === null) { return Effect.succeed({ files, truncated: false }); } - // A change nobody could read in one sitting is not worth a point of budget a page: - // the boxes on screen still work, and the count says it is partial rather than lying. return pagesLeft <= 1 ? Effect.succeed({ files, truncated: true }) : read(page.nextCursor, files, pagesLeft - 1); diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 633291d39f8f..117485a38a76 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -234,9 +234,8 @@ export interface ProviderFilesViewed { /** * What version each of the asked-for files is at, on the change request's head. * - * Opaque strings: the caller only ever compares one against another, and every host names a - * version its own way. The empty string is an answer rather than a gap — it is what a file the - * change request deletes is at, and a mark taken against it stays cleared. + * Opaque strings, compared only against one another. The empty string is an answer rather than a + * gap, the one a file the change request deletes is at, so a mark taken against it stays cleared. * * A path is absent only when the read could not say: a host that answered for part of the change * must leave the rest out rather than report it as deleted, or a file past the cut would be @@ -409,10 +408,8 @@ export interface PullRequestProviderApi { ) => Effect.Effect; /** - * Which files the reader has already cleared. Only called when the host keeps that record - * itself — `capabilities.viewedFiles` of `"host"` — and read apart from the patch: a host that - * reports this at all reports it on a clock of its own, moving with every press rather than - * with every push. + * Which files the reader has already cleared. Only called when `capabilities.viewedFiles` is + * `"host"`, and read apart from the patch: this moves with every press rather than every push. */ readonly getFilesViewed?: ( input: ProviderRepositoryRef & { readonly number: number }, @@ -421,9 +418,8 @@ export interface PullRequestProviderApi { /** * Clears files, or puts them back. Only called when `capabilities.viewedFiles` is `"host"`. * - * Takes several at once because that is how they are pressed. A provider whose host has no - * bulk form still owes one round trip for the batch rather than one per file, since the point - * of gathering them here is that the host is asked once. + * A provider whose host has no bulk form still owes one round trip for the batch rather than + * one per file, since the point of gathering them here is that the host is asked once. */ readonly setFilesViewed?: ( input: ProviderRepositoryRef & { @@ -438,8 +434,7 @@ export interface PullRequestProviderApi { * * The marks live here, but what counts as the same file does not: only the host can say whether * what a reader cleared last week is still what is in front of them. Asked for the marked paths - * alone, so the cost follows how much of the change request has been read rather than how large - * it is. + * alone, so the cost follows how much of the change request has been read, not how large it is. */ readonly getFileRevisions?: ( input: ProviderRepositoryRef & { diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 1c79d45893f7..77b6267e2a45 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -128,10 +128,10 @@ const LIST_STATS_CACHE_TTL = Duration.seconds(60); */ const FILES_VIEWED_CACHE_TTL = Duration.seconds(15); /** - * How long the head's blob for a file is believed without asking the host again, and how long a - * held answer still stands while the next one is fetched. The marks themselves are this - * environment's own rows and cost nothing to read; this is the host call behind the **Changed** - * badge alone, so a held answer costs a badge that is a minute behind rather than a stale tick. + * How long the head's version of a file is believed, and how long a held answer stands while the + * next one is fetched. The marks themselves are this environment's own rows and cost nothing to + * read; this is the host call behind the **Changed** badge alone, so a held answer costs a badge + * that is a minute behind rather than a stale tick. */ const FILE_REVISIONS_CACHE_TTL = Duration.seconds(60); const FILE_REVISIONS_STALE_WINDOW = Duration.minutes(10); @@ -552,8 +552,7 @@ function withRateLimitBackoff( /** * The provider-native repository selector for a project, which everything downstream is keyed by: * the rows' own `repository`, the per-repository cursors, and the detail and diff reads a row - * leads to. The rule itself is shared with the clients that build a ref, since a ref spelled any - * other way is refused before it reaches a provider. + * leads to. */ export function repositoryIdentityOf(project: OrchestrationProjectShell): string | null { return pullRequestRepositoryOf(project.repositoryIdentity); @@ -1553,9 +1552,9 @@ export const make = Effect.gen(function* () { for (const path of paths) { asked.add(path); const revision = answer.get(path); - // Left out of the answer is the host not saying, not the head having nothing: deleting - // the version it last gave would turn a file already reported as changed back into a - // cleared one on the next answer that had to stop short. + // Left out of the answer is the host not saying, not the head having nothing: the + // version it last gave stands, since deleting it would turn a file reported as changed + // back into a cleared one. if (revision !== undefined) revisions.set(path, revision); } heldFileRevisions.delete(key); @@ -1563,10 +1562,9 @@ export const make = Effect.gen(function* () { const oldest = heldFileRevisions.keys().next().value; if (oldest !== undefined) heldFileRevisions.delete(oldest); } - // The entry is only as fresh as the oldest revision in it. Stamping it with now because - // this read answered would let a reader ticking one new file after another keep carrying the - // first file's revision past the point it would have been read again, since every press - // renews the whole scope while asking about one path. + // The entry is only as fresh as the oldest revision in it: stamping it with now would let + // a reader ticking one new file after another carry the first file's revision past the point + // it would have been read again, since every press renews the scope while asking one path. const stamped = [...revisions.keys()].every((path) => answer.has(path)) ? at : (carried?.at ?? at); @@ -1642,8 +1640,7 @@ export const make = Effect.gen(function* () { * * A file the head still has at the revision it was cleared at is cleared; one the head has * moved on from is reported as changed, which is what GitHub says of a file pushed to since it - * was ticked. Revisions are asked for the marked paths alone, so the cost follows how much of - * the change request has been read rather than how large it is, and a reader who has marked + * was ticked. Revisions are asked for the marked paths alone, so a reader who has marked * nothing costs no host call at all. */ const environmentFilesViewed = ( @@ -1656,11 +1653,10 @@ export const make = Effect.gen(function* () { .list(filesViewedScope(project, ref.number, viewer)) .pipe(Effect.mapError(toFilesViewedStoreError("filesViewed"))); if (marks.length === 0) return { files: [], truncated: false }; - // These rows are this environment's own. A rate limit or a signed-out CLI costs the marks - // their staleness, which is the thing `fileRevisionsOf` already answers null for, and must - // not cost the reader every tick they have made. The press itself still fails loudly on a - // host that errors, since a mark stamped with a revision nobody read is wrong rather than - // merely less informed; a host that answers without the path is stored with no baseline. + // A rate limit or a signed-out CLI costs the marks their staleness, which is what + // `fileRevisionsOf` answers null for, not the reader every tick they have made. The press + // itself still fails loudly, since a mark stamped with a revision nobody read is wrong + // rather than merely less informed. const revisions = yield* fileRevisionsOf( project, ref, @@ -1676,15 +1672,10 @@ export const make = Effect.gen(function* () { ); return { files: marks.map((mark) => { - // A path the host had no answer for is one it could not look at, not one it looked at - // and found nothing: a read that saw part of a large change must not report the rest - // as changed against a revision nobody read. A file the change request deletes is - // answered as the empty revision, which is what its mark was stamped with, so it is - // cleared once and stays cleared. - // A mark stamped with no baseline has nothing to compare against, so it holds until - // the reader presses it again. That is the press the host would not answer for, and - // reporting it as changed against a revision it was never measured at would move the - // file the reader just cleared back into the pile. + // A path the host had no answer for is one it could not look at, so the mark holds; a + // file the change request deletes is answered as the empty revision, which is what its + // mark was stamped with, so it is cleared once and stays cleared. A mark stamped with + // no baseline holds for the same reason, until the reader presses it again. if (mark.revision === null) return { path: mark.path, state: "viewed" as const }; const revision = revisions?.get(mark.path); return { @@ -1717,9 +1708,8 @@ export const make = Effect.gen(function* () { write: Effect.Effect, ) => // Suspended rather than generated, so finding the gate, putting it in and taking a place in - // its queue are one step. `Semaphore.make` is an effect, and yielding for it between the - // lookup and the insert lets two presses each find nothing, each make a gate of their own, - // and neither wait on the other, which is the ordering this exists for. + // its queue are one step: yielding for `Semaphore.make` between the lookup and the insert + // lets two presses each make a gate of their own and neither wait on the other. Effect.suspend(() => { const key = `${project.project.id} ${project.remote} ${number}`; const held = filesViewedGates.get(key); @@ -1757,11 +1747,9 @@ export const make = Effect.gen(function* () { yield* filesViewedStore .set({ ...filesViewedScope(project, input.number, viewer), - // A path left out of the answer is the host declining to say, not the head having - // nothing of the file: the empty revision is an answer, and a mark stamped with it is - // reported as changed as soon as the file turns out to have a version after all. Such a - // mark is stored with no baseline instead, and a host too far behind to answer for a - // large change stays tickable rather than clearing files that come straight back. + // A path left out of the answer is the host declining to say, so the mark is stored + // with no baseline rather than with the empty revision, which is an answer and would + // report the file as changed the moment it turns out to have a version after all. files: input.files.map((file) => ({ path: file.path, revision: revisions?.get(file.path) ?? null, diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts index 125333de7491..4d441fd3783e 100644 --- a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts +++ b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts @@ -30,16 +30,14 @@ interface Entry { * quote, a backslash, or, under `core.quotePath`, any byte outside ASCII. * * The escapes are per byte, so a name in any other alphabet arrives as a run of octal and only - * reads back as itself once those bytes are rejoined and decoded together. A name git had no - * reason to quote is already the name. + * reads back as itself once those bytes are rejoined and decoded together. */ function unquotePath(token: string): string { if (token.length < 2 || !token.startsWith(QUOTE) || !token.endsWith(QUOTE)) return token; const body = token.slice(1, -1); const bytes: Array = []; // Anything git left as itself is encoded a run at a time rather than a unit at a time, so a - // character written outside the basic plane keeps its pair together and comes back as itself - // instead of as two halves neither of which is a character. + // character outside the basic plane keeps its pair together rather than coming back as halves. let literal = ""; const flush = () => { if (literal.length === 0) return; diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index c9c94aecd803..e4cc823c9672 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -563,9 +563,8 @@ function PullRequestCodeTab({ () => annotatedFiles.map(({ fileKey, path, fileDiff, annotations, annotationsVersion }) => { const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); - // The header carries the reader's own tick, and the viewer redraws a file only when its - // version moves. Ticking a file that is already folded changes no fold, so without this - // the box on screen would keep saying the opposite of what the count says. + // Ticking a file that is already folded changes no fold, so without this the box on + // screen would keep saying the opposite of what the count says. const viewedMark = filesViewedEnabled ? `e${isFileViewed(path) ? "v" : ""}${isFileViewedStale(path) ? "s" : ""}` : ""; @@ -801,8 +800,7 @@ function PullRequestCodeTab({ // Read through refs rather than closed over. The viewer memoizes each visible file's header // portal on the callback below, so a fresh identity on every tick, and on every refresh of the - // host's answer, would rebuild every header on screen. Each item's version carries the same - // marks, which is what redraws the one file whose tick moved. + // host's answer, would rebuild every header on screen. const filesViewedRef = useRef(filesViewed); filesViewedRef.current = filesViewed; const setFileViewedRef = useRef(setFileViewed); @@ -836,9 +834,8 @@ function PullRequestCodeTab({ return ( {stat} - {/* The header itself folds the file, so the tick has to keep its press to itself. The - attribute is what the header's capture listener looks for: pressing the word next to - the box is pressing the box, and the fold that follows is the tick's to make. */} + {/* The header itself folds the file, so the tick keeps its press to itself. The + attribute is what the header's capture listener looks for. */} ( @@ -492,7 +499,11 @@ function withRateLimitBackoff( const wrapped = { kind: api.kind, capabilities: api.capabilities, - getViewer: wrap("getViewer", api.getViewer), + // A lookup that stands in front of an interactive operation is let through a pause for the + // same reason the operation itself is: refusing the reader their own name while the host backs + // off turns a press they made into a failure, and the lookup's answer is then held for the + // ten minutes that signing in moves on, so a paused host is asked at most once for it. + getViewer: wrap("getViewer", api.getViewer, options.interactiveViewer === true), listChangeRequests: wrap("listChangeRequests", api.listChangeRequests), ...(api.listChangeRequestsAcross === undefined ? {} @@ -679,11 +690,10 @@ export const make = Effect.gen(function* () { if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]); else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot); } - // Rungs for an identity missing its canonical key, the bare selector last because - // Azure's repeats across an organisation. - const remote = - identity.canonicalKey?.trim() || identity.displayName?.trim() || repository; - const key = listCursorKey(host, kind === "azure-devops" ? remote : repository); + const key = listCursorKey( + host, + kind === "azure-devops" ? identity.canonicalKey : repository, + ); if (seen.has(key)) continue; seen.add(key); if (api === null) { @@ -698,7 +708,7 @@ export const make = Effect.gen(function* () { api: withRateLimitBackoff(api, host, rateLimits), repository, host, - remote, + remote: identity.canonicalKey, }); } return { supported, unimplemented, viewerRoots }; @@ -835,16 +845,19 @@ export const make = Effect.gen(function* () { const viewersByHost = new Map(); const viewerFlights = yield* Cache.makeWith( (key: string): Effect.Effect => { - const [host, kind, roots] = JSON.parse(key) as [ + const [host, kind, roots, interactive] = JSON.parse(key) as [ string, SourceControlProviderKind, ReadonlyArray, + boolean, ]; const registered = registry.get(kind); if (registered === null) { return Effect.die(new Error(`Missing pull request provider: ${kind}`)); } - const api = withRateLimitBackoff(registered, host, rateLimits); + const api = withRateLimitBackoff(registered, host, rateLimits, { + interactiveViewer: interactive, + }); return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe( Effect.map((viewer) => ({ host, @@ -877,6 +890,8 @@ export const make = Effect.gen(function* () { const resolveViewers = ( projects: ReadonlyArray, viewerRoots: WorkspaceProjects["viewerRoots"], + /** Whether the reader is waiting on what this lookup stands in front of. */ + interactive = false, ) => Effect.forEach( [...new Set(projects.map(({ host }) => host))], @@ -892,7 +907,7 @@ 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); - const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]); + const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort(), interactive]); return Cache.get(viewerFlights, key); }), { concurrency: REPOSITORY_CONCURRENCY }, @@ -1572,15 +1587,17 @@ export const make = Effect.gen(function* () { /** * Who the host says the reader is, for the paths whose rows are keyed by it. A lookup that - * failed is refused rather than answered as the unnamed reader: a rate-limited or momentarily - * signed-out CLI would otherwise hide every tick this reader has made and file the next press - * under rows that are orphaned once it recovers. + * failed is refused rather than answered as the unnamed reader: a momentarily signed-out CLI + * would otherwise hide every tick this reader has made and file the next press under rows that + * are orphaned once it recovers. The reader is waiting on every one of these paths, a press or + * the boxes on a diff they just opened, so the lookup is let through a host's backoff rather + * than failing with it and turning a pause into a refusal. */ const requiredViewerOf = ( project: SupportedProject, operation: string, ): Effect.Effect => - resolveViewers([project], new Map()).pipe( + resolveViewers([project], new Map(), true).pipe( Effect.flatMap(([resolved]) => { const error = resolved?.error ?? null; return error === null diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts index c111d979da39..7ccf9e724c16 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts @@ -155,11 +155,13 @@ describe("azureDevOpsFilePatch", () => { const lineRange = (count: number, prefix: string) => Array.from({ length: count }, (_, line) => `${prefix} ${line}`).join("\n"); - it("lists a file too far apart to diff as wholly replaced", () => { + it("lists a file too far apart to diff without its hunks", () => { // Sharing no line at all costs one edit per line on each side, so this pair is twice the // ceiling apart. Left to itself the search costs about the square of that and would hold the - // whole server, every websocket client with it, while it worked out a patch nobody reads. What - // the two sides are is known without any search, so the reader gets them. + // whole server, every websocket client with it, while it worked out a patch nobody reads. + // Writing both sides out instead would read as a genuine rewrite: the ceiling is a distance + // rather than a proportion, so a long file reaches it having changed in one corner, and that + // corner would be buried in a wall of red and green. const lines = (prefix: string) => Array.from({ length: MAX_FILE_DIFF_EDITS }, (_, line) => `${prefix} ${line}`).join("\n"); const patch = azureDevOpsFilePatch({ @@ -171,9 +173,14 @@ describe("azureDevOpsFilePatch", () => { // And it says the search was given up on, because the reader of a run of files is meant to // stop rather than spend that work again on each of the ones behind it. expect(patch.abandoned).toBe(true); - expect(patch.section).toContain(`@@ -1,${MAX_FILE_DIFF_EDITS} +1,${MAX_FILE_DIFF_EDITS} @@`); - expect(patch.section.match(/^-old /gmu)).toHaveLength(MAX_FILE_DIFF_EDITS); - expect(patch.section.match(/^\+new /gmu)).toHaveLength(MAX_FILE_DIFF_EDITS); + expect(patch.section).toBe( + [ + "diff --git a/generated.ts b/generated.ts", + "--- a/generated.ts", + "+++ b/generated.ts", + "", + ].join("\n"), + ); }); it("writes out a wholly new file however many lines it has", () => { @@ -197,11 +204,12 @@ describe("azureDevOpsFilePatch", () => { expect(patch.section.match(/^\+/gmu)).toHaveLength(15_001); }); - it("writes out a wholly new file whose patch weighs more than one file is let through at", () => { - // Every line carries a prefix, so a side of very short lines answers with up to twice its own - // bytes. A two-sided file declines to be written out at that size, because there was a real - // diff it was only standing in for. A creation has no smaller true patch to fall back to, and - // the byte ceiling on each side is what bounds it instead. + it("keeps a wholly new file too heavy to write out listed without its hunks", () => { + // Every line carries a marker, so a side of very short lines answers with up to twice its own + // bytes: these 200,000 one-character lines fit the ceiling each side is read under and weigh + // about 600KB written out, more than twice what a whole slice may carry. There is no smaller + // true patch for a creation to fall back to, so it is listed without its hunks, the same as a + // side too big to read at all, rather than sent at a size the slice budget exists to prevent. const contents = `${Array.from({ length: 200_000 }, () => "x").join("\n")}\n`; const patch = azureDevOpsFilePatch({ change: change({ path: "bundle.min.js", oldPath: "bundle.min.js", changeKind: "new" }), @@ -209,10 +217,19 @@ describe("azureDevOpsFilePatch", () => { }); expect(byteLength(contents)).toBeLessThan(512 * 1024); - expect(byteLength(patch.section)).toBeGreaterThan(512 * 1024); - expect(patch.truncated).toBe(false); + expect(patch.truncated).toBe(true); + expect(patch.abandoned).toBe(false); + // It still cost the walk over its lines, which is what the slice is charged for. expect(patch.edits).toBe(200_000); - expect(patch.section).toContain("@@ -0,0 +1,200000 @@"); + expect(patch.section).toBe( + [ + "diff --git a/bundle.min.js b/bundle.min.js", + "new file mode 100644", + "--- /dev/null", + "+++ b/bundle.min.js", + "", + ].join("\n"), + ); }); it("writes out a wholly deleted file however many lines it had", () => { @@ -242,60 +259,22 @@ describe("azureDevOpsFilePatch", () => { expect(patch.section).not.toContain("@@"); }); - it("marks a replaced side that does not end in a newline", () => { - const lines = (prefix: string) => - Array.from({ length: MAX_FILE_DIFF_EDITS }, (_, line) => `${prefix} ${line}`).join("\n"); - const patch = azureDevOpsFilePatch({ - change: change(), - texts: texts(`${lines("old")}\n`, lines("new")), - }); - - expect(patch.abandoned).toBe(true); - expect(patch.section.match(/^\\ No newline at end of file$/gmu)).toHaveLength(1); - expect(patch.section).toContain( - `+new ${MAX_FILE_DIFF_EDITS - 1}\n\\ No newline at end of file\n`, - ); - }); - - it("keeps a file too long to call wholly replaced listed without its hunks", () => { - // Past a few thousand lines, being further apart than the ceiling no longer means the sides - // share little: the file may have changed in one corner, and calling it wholly replaced would - // bury that corner in a wall of red and green. - const lines = (prefix: string) => - Array.from({ length: 5_000 }, (_, line) => `${prefix} ${line}`).join("\n"); + it("marks a wholly new file whose last line has no newline after it", () => { const patch = azureDevOpsFilePatch({ - change: change({ path: "generated.ts", oldPath: "generated.ts" }), - texts: texts(`${lines("old")}\n`, `${lines("new")}\n`), + change: change({ path: "NOTES.md", oldPath: "NOTES.md", changeKind: "new" }), + texts: texts("", "one\ntwo"), }); - expect(patch.abandoned).toBe(true); expect(patch.section).toBe( [ - "diff --git a/generated.ts b/generated.ts", - "--- a/generated.ts", - "+++ b/generated.ts", - "", - ].join("\n"), - ); - }); - - it("keeps a replacement heavier than one file's bytes listed without its hunks", () => { - // Few enough lines to be worth calling wholly replaced, and long enough lines that saying so - // would answer with twice what either side was let through at. - const wide = "z".repeat(200); - const lines = (prefix: string) => - Array.from({ length: 2_000 }, (_, line) => `${prefix} ${line} ${wide}`).join("\n"); - const patch = azureDevOpsFilePatch({ - change: change({ path: "generated.ts", oldPath: "generated.ts" }), - texts: texts(`${lines("old")}\n`, `${lines("new")}\n`), - }); - - expect(patch.abandoned).toBe(true); - expect(patch.section).toBe( - [ - "diff --git a/generated.ts b/generated.ts", - "--- a/generated.ts", - "+++ b/generated.ts", + "diff --git a/NOTES.md b/NOTES.md", + "new file mode 100644", + "--- /dev/null", + "+++ b/NOTES.md", + "@@ -0,0 +1,2 @@", + "+one", + "+two", + "\\ No newline at end of file", "", ].join("\n"), ); diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts index 3d5fe231f08a..05d27350933f 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -88,27 +88,20 @@ const PATCH_CONTEXT_LINES = 3; * client with it, while it works out a patch of tens of thousands of lines nobody reads. Bounded in * edits rather than in milliseconds so a change slices the same way on every machine. * - * Measured at around 210ms for a pair at the size ceiling that shares no line at all, which is the - * longest this can hold the thread for one file. + * Measured at up to about 175ms for a pair at the size ceiling that shares no line at all, and at + * about 800ms for the same pair on a slower machine, which is the longest this can hold the thread + * for one file. */ export const MAX_FILE_DIFF_EDITS = 2_000; /** - * How many lines a file may be listed as wholly replaced by when its diff was given up on. The - * edit ceiling is a distance rather than a proportion, so a long file can exceed it having changed - * in one corner only, and calling that a whole replacement would be a wall of red and green hiding - * the part that moved. Four times the ceiling keeps the claim within reach of what is known to - * differ: measured against this repository's own history, no section it admits overstates the real - * change by more than about a factor of two. + * A backstop for a machine slower than any the edit ceiling was measured on. It sits several times + * above what that ceiling costs, because a timeout within reach of it would decide the shape of a + * patch by how fast the machine is: the same change would slice one way here and another on a + * busier host, and a file the ceiling admits would lose its hunks on the slower of the two. + * Nothing within the ceiling comes near this, so it changes no patch. */ -const MAX_FULL_REPLACEMENT_LINES = 4 * MAX_FILE_DIFF_EDITS; - -/** - * A backstop for a machine slower than the one the edit ceiling was measured on. Nothing within - * that ceiling comes near this on ordinary hardware, so it changes no patch; it is only here so - * the longest one file can hold the thread stays a number rather than a hope. - */ -const MAX_FILE_DIFF_MILLIS = 500; +const MAX_FILE_DIFF_MILLIS = 2_000; /** * How much diff work one slice does before the rest is left for the next one, which bounds what a @@ -175,8 +168,8 @@ function patchHeader(change: AzureDevOpsChangeEntry): string { /** * A file written out as wholly replaced: every old line gone, every new line arrived, in one hunk. - * Costs no search at all, around 45ns a line, so it is both the whole patch for a file that has - * only one side and a stand-in for one whose real diff was given up on. + * Costs no search at all, around 45ns a line, which is what makes it the whole patch for a file + * that has only one side. */ function replacementSection(header: string, texts: AzureDevOpsFileTexts): string { const oldLines = contentLines(texts.oldContents); @@ -194,19 +187,6 @@ function replacementSection(header: string, texts: AzureDevOpsFileTexts): string ].join("\n"); } -/** - * The same section, for a file that has two sides and so a real diff that this is only standing in - * for. Null where the claim would be too loose to make or too heavy to send, leaving the file - * listed without its hunks. - */ -function boundedReplacementSection(header: string, texts: AzureDevOpsFileTexts): string | null { - const lines = contentLines(texts.oldContents).length + contentLines(texts.newContents).length; - if (lines > MAX_FULL_REPLACEMENT_LINES) return null; - const section = replacementSection(header, texts); - // One file's worth of bytes, the same ceiling its two sides were each let through under. - return byteLength(section) > MAX_FILE_BYTES ? null : section; -} - /** * One file's section of a unified patch, built here because Azure has no route that carries one: * its diff routes name the files that changed and their blob ids, and the contents are a separate @@ -236,12 +216,14 @@ export function azureDevOpsFilePatch(input: { const deleted = newContents === "" && oldContents !== ""; if (created || deleted) { const lines = contentLines(created ? newContents : oldContents); - return { - section: replacementSection(header, input.texts), - truncated: false, - abandoned: false, - edits: lines.length, - }; + const section = replacementSection(header, input.texts); + // A marker on every line puts a side that just fits the size ceiling half again over it, and + // what one file weighs is what a slice's budget is spent in. Such a file is listed without its + // hunks, the same as one whose sides were too big to read at all. + if (byteLength(section) > MAX_FILE_BYTES) { + return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; + } + return { section, truncated: false, abandoned: false, edits: lines.length }; } const patch = structuredPatch( @@ -257,14 +239,15 @@ export function azureDevOpsFilePatch(input: { timeout: MAX_FILE_DIFF_MILLIS, }, ); - // The bound is reported by giving nothing back. Such a file is listed as wholly replaced where - // that is close enough to the truth to say, and listed without its hunks otherwise, rather than - // dropped from the change. Either way it spent the whole of what one file is allowed to get here, - // which is what `edits` carries: writing the replacement out costs nothing on top. + // The bound is reported by giving nothing back. Such a file is listed without its hunks rather + // than dropped from the change, and rather than written out as wholly replaced: the edit ceiling + // is a distance rather than a proportion, so a long file can reach it having changed in one + // corner, and both sides in full would read as a genuine rewrite and bury that corner in a wall + // of red and green. It spent the whole of what one file is allowed to get here, which is what + // `edits` carries, so the caller reading a run of files stops rather than paying that again. if (patch === undefined) { - const replaced = boundedReplacementSection(header, input.texts); return { - section: replaced ?? `${header}\n`, + section: `${header}\n`, truncated: true, abandoned: true, edits: MAX_FILE_DIFF_EDITS, diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index d033a94ad1db..2ba54147376a 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -229,10 +229,11 @@ export const make = (dependencies: Dependencies) => { .list(filesViewedScope(project, ref.number, viewer)) .pipe(Effect.mapError(toFilesViewedStoreError("filesViewed"))); if (marks.length === 0) return { files: [], truncated: false }; - // A rate limit or a signed-out CLI costs the marks their staleness, which is what - // `fileRevisionsOf` answers null for, not the reader every tick they have made. The press - // itself still fails loudly, since a mark stamped with a revision nobody read is wrong - // rather than merely less informed. + // A host that will not say what its head has of a file costs the marks their staleness, + // which is what `fileRevisionsOf` answers null for, rather than costing the reader every + // tick they have made. Who the reader is, above, cannot give way like that: these rows are + // keyed by it, so a lookup that failed is reported, and the client says the marks could not + // be read rather than drawing a reader with marks as one with none. const revisions = yield* fileRevisionsOf( project, ref, @@ -318,7 +319,19 @@ export const make = (dependencies: Dependencies) => { const revisions = cleared.length === 0 ? null - : yield* fileRevisionsOf(project, input, cleared, "setFilesViewed", "fresh"); + : yield* fileRevisionsOf(project, input, cleared, "setFilesViewed", "fresh").pipe( + // A host that will not say what its head has, because it is backing off or because + // the CLI is having a bad minute, costs the press its baseline rather than costing + // the reader the press. The mark is stored with none, which holds until it is + // pressed again: the file stops reporting staleness, and nothing is stamped with a + // revision that was never read. + Effect.catch((error) => + Effect.logWarning("recording viewed files without what the head has of them", { + operation: "setFilesViewed", + reason: error._tag, + }).pipe(Effect.as(null)), + ), + ); const viewedAt = DateTime.formatIso(yield* DateTime.now); yield* filesViewedStore .set({ diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index e4cc823c9672..7165414dcfc1 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -1205,6 +1205,20 @@ function PullRequestCodeTab({ ) : null} + {filesViewed.error !== null ? ( + + }> + + + + The boxes below are whatever was last read, and empty if nothing has been read + yet. {filesViewed.error} + + + ) : null} {filesViewed.truncated ? ( }> diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index 8d5efa8d99c1..1cf3dc7bb93a 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -38,6 +38,12 @@ export interface PullRequestFilesViewedView { readonly viewedCount: number; /** The host had more files than the read covered, so the count above may be short. */ readonly truncated: boolean; + /** + * Why the marks could not be read, when they could not. The boxes fall back to the last answer + * there was, or to empty when there has not been one, and neither of those says so on its own: + * a reader who sees every box unticked has no way to tell a fresh review from a failed read. + */ + readonly error: string | null; /** Re-ask the host, for the page's refresh button, which goes around the host's cache. */ readonly refresh: () => void; } @@ -61,8 +67,12 @@ export function usePullRequestFilesViewed(options: { enabled ? pullRequestEnvironment.filesViewed({ environmentId, input: reference }) : null, ); const refresh = query.refresh; + // `query.data` holds the last answer through a failure, so the boxes stay where the host last + // put them rather than emptying under the reader; the error travels with them, because ticks + // that stopped being refreshed look exactly like ticks that are current. const states = useMemo(() => toFileViewedStates(query.data), [query.data]); const truncated = query.data?.truncated === true; + const error = query.error; const [overlay, setOverlay] = useState(NO_OVERLAY); const setFilesViewed = useAtomCommand(pullRequestEnvironment.setFilesViewed, { reportFailure: false, @@ -181,8 +191,9 @@ export function usePullRequestFilesViewed(options: { setViewed, viewedCount, truncated, + error, refresh: refreshFromHost, }), - [enabled, isStale, isViewed, refreshFromHost, setViewed, truncated, viewedCount], + [enabled, error, isStale, isViewed, refreshFromHost, setViewed, truncated, viewedCount], ); } From 900698c275c2a567dcf1c4b580044e91a023c9c0 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 19:52:55 -0400 Subject: [PATCH 52/78] fix(server): bound how many az processes the whole build has out at once - The read fan-out bounds a single Code tab, so two readers on two Azure reviews were twice a request's Python interpreters starting at once with nothing above them, on a machine already running the agents. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestProvider.test.ts | 42 ++++++++++++++++++- .../AzureDevOpsPullRequestProvider.ts | 18 +++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts index 5067a1e2b917..b45ba9b74e53 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; -import { make } from "./AzureDevOpsPullRequestProvider.ts"; +import { make, MAX_DIFF_SPAWNS } from "./AzureDevOpsPullRequestProvider.ts"; import { MAX_DIFF_SLICE_BYTES, MAX_FILE_DIFF_EDITS } from "./azureDevOpsDiff.ts"; import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; @@ -125,6 +125,46 @@ function patchedPaths(patch: string): ReadonlyArray { } describe("getDiff reads", () => { + it.effect("holds every reader together to one request's worth of processes", () => + Effect.gen(function* () { + // The fan-out inside a read bounds one Code tab. Two people opening two Azure reviews at + // once are two reads, so without a ceiling above them both they are twice a request's + // processes, each paying a Python interpreter's start-up on the same machine. + const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; + let inFlight = 0; + let peakInFlight = 0; + + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => Effect.succeed(PULL_REQUEST), + listIterations: () => Effect.succeed([ITERATION]), + listIterationChanges: () => + Effect.succeed({ changes: paths.map((path) => change(path)), truncated: false }), + readItemContent: () => + Effect.gen(function* () { + inFlight += 1; + peakInFlight = Math.max(peakInFlight, inFlight); + // Suspends before answering, as a subprocess would, so what is out at once is the + // scheduler's answer rather than an artefact of resolving inline. + yield* Effect.yieldNow; + yield* Effect.yieldNow; + inFlight -= 1; + return { contents: side("new", 2, 4), isBinary: false }; + }), + }), + ), + ); + + const readDiff = (number: number) => + provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number }); + + yield* Effect.all([readDiff(7), readDiff(8)], { concurrency: 2 }); + + expect(peakInFlight).toBeLessThanOrEqual(MAX_DIFF_SPAWNS); + }), + ); + it.effect("asks for both sides of several files at once rather than one side at a time", () => Effect.gen(function* () { // Each file is two `az` invocations, each paying a Python interpreter's start-up, so a diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 38c391ba3fea..879b92af7b80 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as Semaphore from "effect/Semaphore"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; @@ -39,6 +40,14 @@ import type { */ const DIFF_FILE_CONCURRENCY = 4; +/** + * How many `az` processes this build will have out at once, counted across every reader rather + * than per request. The fan-out above bounds one Code tab, so two people opening two Azure reviews + * had sixteen Python interpreters starting at once and nothing above them. Held at what one + * request at full width spends, so a second reader waits behind the first instead of adding to it. + */ +export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY; + const CAPABILITIES: PullRequestCapabilities = { // Azure serves no patch of its own, so the one the Code tab reads is built here out of the // files an iteration changed and both sides of each of them. @@ -134,6 +143,11 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq export const make = Effect.gen(function* () { const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + // Made once with the provider, which the registry builds once, so this is the whole build's + // allowance rather than one request's. + const diffSpawns = yield* Semaphore.make(MAX_DIFF_SPAWNS); + const readItemContent = (input: Parameters[0]) => + diffSpawns.withPermits(1)(cli.readItemContent(input)); const fail = (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) => @@ -224,7 +238,7 @@ export const make = Effect.gen(function* () { [ input.change.changeKind === "new" ? Effect.succeed(EMPTY_ITEM) - : cli.readItemContent({ + : readItemContent({ cwd: input.cwd, location: input.location, path: input.change.oldPath, @@ -232,7 +246,7 @@ export const make = Effect.gen(function* () { }), input.change.changeKind === "deleted" ? Effect.succeed(EMPTY_ITEM) - : cli.readItemContent({ + : readItemContent({ cwd: input.cwd, location: input.location, path: input.change.path, From 7383233c09bfd90dcce499461200e6d929044b8a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 21:43:23 -0400 Subject: [PATCH 53/78] fix(server): bound what one change request can hold and spawn Each of these had a shape a real change request reaches: a directory of vendored assets, a minified bundle, or a reader who has ticked through thousands of files. Reaching it cost hundreds of megabytes resident, or thousands of subprocesses for one request, rather than a slower read. Signed-off-by: Yordis Prieto --- .../src/persistence/PullRequestFilesViewed.ts | 28 ++++++- .../AzureDevOpsPullRequestProvider.test.ts | 28 ++++++- .../AzureDevOpsPullRequestProvider.ts | 9 ++- .../pullRequest/BitbucketPullRequestApi.ts | 19 ++++- .../pullRequest/GitHubPullRequestCli.test.ts | 37 +++++++++ .../src/pullRequest/GitHubPullRequestCli.ts | 20 ++++- .../pullRequest/PullRequestService.test.ts | 79 ++++++++++++++++++- .../src/pullRequest/PullRequestService.ts | 34 +++----- .../src/pullRequest/azureDevOpsDiff.test.ts | 19 +++++ .../server/src/pullRequest/azureDevOpsDiff.ts | 45 ++++++++--- .../src/pullRequest/pullRequestViewedFiles.ts | 11 ++- packages/contracts/src/pullRequest.test.ts | 27 +++++++ packages/contracts/src/pullRequest.ts | 18 ++++- 13 files changed, 318 insertions(+), 56 deletions(-) diff --git a/apps/server/src/persistence/PullRequestFilesViewed.ts b/apps/server/src/persistence/PullRequestFilesViewed.ts index 488938c4c59f..af7856de272d 100644 --- a/apps/server/src/persistence/PullRequestFilesViewed.ts +++ b/apps/server/src/persistence/PullRequestFilesViewed.ts @@ -56,6 +56,20 @@ export interface SetPullRequestFilesViewedInput extends PullRequestFilesViewedSc readonly viewedAt: string; } +/** + * How many marks one read of this store carries. Every one of them is a path held in a set and a + * map for as long as the caller holds the read, so an unbounded read of a change request with + * thousands of marks in it is paid for again per scope the caller is holding. Matched to what the + * GitHub reader walks in one go, past what anyone reviews in a sitting. + */ +export const MAX_FILES_VIEWED_ROWS = 500; + +/** The marks for one scope, and whether the store had more of them than it carried. */ +export interface PullRequestFilesViewedPage { + readonly files: ReadonlyArray; + readonly truncated: boolean; +} + /** * The marks this environment keeps for hosts that keep none of their own. * @@ -67,10 +81,7 @@ export class PullRequestFilesViewedRepository extends Context.Service< { readonly list: ( input: PullRequestFilesViewedScope, - ) => Effect.Effect< - ReadonlyArray, - PullRequestFilesViewedRepositoryError - >; + ) => Effect.Effect; readonly set: ( input: SetPullRequestFilesViewedInput, ) => Effect.Effect; @@ -101,12 +112,21 @@ const make = Effect.gen(function* () { AND repository = ${repository} AND number = ${number} AND viewer = ${viewer} + ORDER BY path + LIMIT ${MAX_FILES_VIEWED_ROWS + 1} `, }); return PullRequestFilesViewedRepository.of({ + // Ordered by path and read one row past the ceiling, so the same marks come back on every + // read rather than a window that shuffles, and having more than were carried is known rather + // than guessed at from a full page. list: (input) => listRows(input).pipe( + Effect.map((rows) => ({ + files: rows.slice(0, MAX_FILES_VIEWED_ROWS), + truncated: rows.length > MAX_FILES_VIEWED_ROWS, + })), Effect.mapError(toSqlOrDecodeError("listPullRequestFilesViewed", "PullRequestFileViewed")), ), diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts index b45ba9b74e53..5b1b502d141d 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts @@ -4,7 +4,13 @@ import * as Layer from "effect/Layer"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import { make, MAX_DIFF_SPAWNS } from "./AzureDevOpsPullRequestProvider.ts"; -import { MAX_DIFF_SLICE_BYTES, MAX_FILE_DIFF_EDITS } from "./azureDevOpsDiff.ts"; +import { + byteLength, + MAX_DIFF_SLICE_BYTES, + MAX_DIFF_SLICE_FILES, + MAX_FILE_DIFF_EDITS, + parseAzureDevOpsDiffCursor, +} from "./azureDevOpsDiff.ts"; import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; const ITERATION = { id: 3, headCommit: "head", mergeBaseCommit: "base" }; @@ -165,6 +171,26 @@ describe("getDiff reads", () => { }), ); + it.effect("leaves a run of files it could not diff at all for the next slice", () => + Effect.gen(function* () { + // A binary, oversize, purely renamed or unreadable entry is a header apiece, a couple of + // hundred bytes with no edits in it, so a change made of them spends neither budget: the + // byte one would take well over a thousand of them, and the edit one never fills at all. + // A listing holds up to ten thousand entries and each still costs its two reads, which is + // what a file count is here to bound. + const paths = Array.from({ length: MAX_DIFF_SLICE_FILES + 20 }, (_, at) => `gen/a${at}.bin`); + const read = yield* readSlice({ paths, lines: 2, width: 4, refused: paths }); + + expect(patchedPaths(read.slice.patch)).toHaveLength(MAX_DIFF_SLICE_FILES); + // Well inside the byte budget, so the file count is what stopped it rather than either of + // the budgets that were already there. + expect(byteLength(read.slice.patch)).toBeLessThan(MAX_DIFF_SLICE_BYTES); + expect(parseAzureDevOpsDiffCursor(read.slice.nextCursor)?.fileIndex).toBe( + MAX_DIFF_SLICE_FILES, + ); + }), + ); + it.effect("asks for both sides of several files at once rather than one side at a time", () => Effect.gen(function* () { // Each file is two `az` invocations, each paying a Python interpreter's start-up, so a diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 879b92af7b80..9a42c42025ce 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -10,6 +10,7 @@ import { parseAzureDevOpsDiffCursor, MAX_DIFF_SLICE_BYTES, MAX_DIFF_SLICE_EDITS, + MAX_DIFF_SLICE_FILES, byteLength, MAX_FILE_DIFF_EDITS, type AzureDevOpsFileTexts, @@ -431,6 +432,8 @@ export const make = Effect.gen(function* () { 1, Math.min( DIFF_FILE_CONCURRENCY, + // The file budget needs no estimate: a file spends exactly one of it. + MAX_DIFF_SLICE_FILES - sections.length, admits(MAX_DIFF_SLICE_BYTES - bytes, bytes), admits(MAX_DIFF_SLICE_EDITS - MAX_FILE_DIFF_EDITS - edits, edits), ), @@ -472,13 +475,17 @@ export const make = Effect.gen(function* () { index += 1; // A file whose diff was given up on spent the whole of what one file is allowed and // has only a header to show for it, so the byte budget alone would let a change full - // of them spend that over and over in one request. Checked after the file is added + // of them spend that over and over in one request. A file the diff never ran on at + // all, because it is binary or oversize or only renamed, weighs almost nothing in + // either budget and still costs its two reads, which is what the file count bounds. + // Checked after the file is added // rather than before it, so every slice carries at least one: a section heavier than // the whole budget would otherwise never be added, and the read would answer the same // slice forever without moving the cursor. if ( bytes >= MAX_DIFF_SLICE_BYTES || edits + MAX_FILE_DIFF_EDITS > MAX_DIFF_SLICE_EDITS || + sections.length >= MAX_DIFF_SLICE_FILES || file.abandoned ) { full = true; diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index 2cd6b1997674..0f36b020e802 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -578,11 +578,23 @@ export const make = Effect.gen(function* () { ), ); - /** The pull request's whole patch, shared by the version reads that come one tick at a time. */ + /** + * What the version reads that come one tick at a time actually want out of the pull request's + * whole patch, shared between them. The patch itself is not what is held: at this capacity that + * would be sixteen bodies of up to the byte ceiling each resident, and V8 stores a body with a + * single non-Latin-1 character anywhere in it two bytes to the character. This is the same + * answer some thousands of times smaller, and it saves walking a patch of a hundred thousand + * lines again on every tick. + */ const revisionPatches = yield* Cache.makeWith( (key: string) => { const [repository, number] = JSON.parse(key) as [string, number]; - return pullRequestDiff({ repository, number }); + return pullRequestDiff({ repository, number }).pipe( + Effect.map((diff) => ({ + revisions: parseDiffFileRevisions(diff.patch), + truncated: diff.truncated, + })), + ); }, { capacity: REVISION_PATCH_CAPACITY, @@ -670,12 +682,11 @@ export const make = Effect.gen(function* () { ? Effect.succeed(new Map()) : Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe( Effect.map((diff) => { - const all = parseDiffFileRevisions(diff.patch); // A patch cut short at the byte ceiling says nothing about the files past the cut, // so those paths are left out rather than reported as removed. const asked = new Map(); for (const path of input.paths) { - const revision = all.get(path); + const revision = diff.revisions.get(path); if (revision !== undefined) asked.set(path, revision); else if (!diff.truncated) asked.set(path, ""); } diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 130c6bc9357b..686adff65567 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -3684,4 +3684,41 @@ layer("GitHubPullRequestCli.layer", (it) => { expect(idSentAt(2)).toEqual("PR_25"); }), ); + it.effect("keeps the pull request being ticked through, not the one looked up first", () => + Effect.gen(function* () { + // Ordered by insertion alone, a hit does not renew its entry, so the review the reader is + // working down is the first thing evicted once a listing has walked a cache's worth of cold + // pull requests, and every press after that pays a round trip again. + // This block shares one cache, so these numbers are its own and it runs last. + const HOT = 9_000; + const lookupsOf = new Map(); + mockedExecute.mockImplementation((input) => { + const asked = input.args.find((arg) => arg.startsWith("number=")); + if (asked === undefined) return Effect.succeed(output("{}")); + const number = Number(asked.slice("number=".length)); + lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1); + return Effect.succeed( + output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), + ); + }); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const tick = (number: number) => + cli.setPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number, + files: [{ path: "src/a.ts", viewed: true }], + }); + + yield* tick(HOT); + // A cache's worth of cold pull requests, with the open one pressed in between each of them. + for (let filled = 0; filled < GitHubPullRequestCli.NODE_ID_CACHE_CAPACITY; filled += 1) { + yield* tick(HOT + 1 + filled); + yield* tick(HOT); + } + + assert.strictEqual(lookupsOf.get(HOT), 1); + }), + ); }); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index ae6d7716ddf9..74ed9842b28e 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -376,6 +376,13 @@ const DIFF_FILES_PAGE_SIZE = 100; */ const FILES_VIEWED_MAX_PAGES = 5; +/** + * How many pull requests' node ids are remembered at once. A long-lived server sees far more of + * them than a reader ever has open, and least recently used rather than first in: a listing + * walking cold pull requests must not evict the review being ticked through. + */ +export const NODE_ID_CACHE_CAPACITY = 128; + /** * Pages of review threads to follow before the conversation is reported as truncated. GitHub * serves a hundred threads a page, so this is a thousand threads — past anything a pull request @@ -1036,10 +1043,8 @@ export const make = Effect.gen(function* () { * addressed by: a reaction on its description, or a rewrite of its words. * * A pull request keeps its node id for life, so it is remembered rather than re-read: a reader - * ticking files viewed would otherwise pay a GraphQL round trip per press. Bounded, since a - * long-lived server sees far more pull requests than a reader ever has open. + * ticking files viewed would otherwise pay a GraphQL round trip per press. */ - const NODE_ID_CACHE_CAPACITY = 128; const nodeIds = new Map(); const pullRequestNodeId = (input: { @@ -1052,7 +1057,14 @@ export const make = Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); const key = `${input.host} ${owner}/${name} ${input.number}`; const held = nodeIds.get(key); - if (held !== undefined) return Effect.succeed(held); + if (held !== undefined) { + // Put back at the end on every hit, so what falls out is the pull request nobody has looked + // at rather than the one being ticked through: a run of cold reads would otherwise evict the + // open review and make it pay a round trip per press. + nodeIds.delete(key); + nodeIds.set(key, held); + return Effect.succeed(held); + } return graphqlRead({ cwd: input.cwd, host: input.host, diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 4549b32913a8..615dcc3f906f 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1523,10 +1523,20 @@ it.effect("uses a manual rate limit to pause later reads", () => action: "close", }), ); - const error = yield* Effect.flip(service.list({ state: "open", involvement: "all" })); + const paused = yield* service.list({ state: "open", involvement: "all" }); + // The listing itself never reaches the host, and the repository behind it is reported as one + // that could not be read. assert.strictEqual(listCalls, 0); - assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.deepStrictEqual(paused.entries, []); + assert.deepStrictEqual( + paused.errors.map((error) => error.projectId), + ["p1"], + ); + // Who is signed in is not what a pause holds back. It is asked once per host per ten minutes + // and it stands in front of everything else here, so refusing it would report a host that is + // merely backing off as one nobody is signed in to. + assert.strictEqual(paused.viewers["github.com"], "bilal"); }), ); @@ -5228,6 +5238,71 @@ it.effect("keeps one reader's marks on a host that names nobody", () => }), ); +it.effect("puts a listing and a press for one host on a single viewer lookup", () => + Effect.gen(function* () { + let viewerLookups = 0; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + { + ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), + getViewer: () => + Effect.gen(function* () { + viewerLookups += 1; + // Suspends before answering, as a subprocess would, so both callers are in flight + // at once rather than the second finding the first has already answered. + yield* Effect.yieldNow; + return "bilal"; + }), + }, + ], + }); + + // What a cold page load does: read the listing and the reader's own marks at the same time. + // Nothing about which of them asked is in the lookup's key, so they wait on one CLI between + // them rather than starting one each. + yield* Effect.all([service.list({ state: "open" }), service.filesViewed(GITLAB_REFERENCE)], { + concurrency: 2, + }); + + assert.strictEqual(viewerLookups, 1); + }), +); + +it.effect("carries a bounded number of its own marks and says it held more", () => + Effect.gen(function* () { + const asked: Array> = []; + const paths = Array.from( + { length: PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS + 40 }, + (_, at) => `src/f${String(at).padStart(4, "0")}.ts`, + ); + const service = yield* environmentViewedService( + new Map(paths.map((path) => [path, "blob"] as const)), + asked, + ); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: paths.map((path) => ({ path, viewed: true })), + }); + const read = yield* service.filesViewed(GITLAB_REFERENCE); + + // Every mark read is a path held in a set and a map for as long as the caller holds the read, + // per scope it is holding, so the rows are bounded rather than however many a reader has ever + // ticked. The reader is told the count is short rather than shown a quietly clipped list. + assert.lengthOf(read.files, PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS); + assert.strictEqual(read.truncated, true); + }), +); + it.effect("records a press while the host is backing off", () => Effect.gen(function* () { let viewerLookups = 0; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a7ad2ed9f11d..a767d97e1481 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -443,13 +443,6 @@ function withRateLimitBackoff( api: PullRequestProviderApi, host: string, limits: SourceControlRateLimit.SourceControlRateLimit["Service"], - options: { - /** - * Whether this provider's viewer lookup stands in front of something the reader is waiting - * on, rather than in front of a listing that can wait for the host to recover. - */ - readonly interactiveViewer?: boolean; - } = {}, ): PullRequestProviderApi { const key = { provider: api.kind, host }; const protect = ( @@ -500,11 +493,12 @@ function withRateLimitBackoff( const wrapped = { kind: api.kind, capabilities: api.capabilities, - // A lookup that stands in front of an interactive operation is let through a pause for the - // same reason the operation itself is: refusing the reader their own name while the host backs - // off turns a press they made into a failure, and the lookup's answer is then held for the - // ten minutes that signing in moves on, so a paused host is asked at most once for it. - getViewer: wrap("getViewer", api.getViewer, options.interactiveViewer === true), + // Let through a pause, whoever asked. This lookup stands in front of everything else here, + // so refusing it turns a paused host into one that reads as signed out, and refusing it for a + // press the reader made turns that press into a failure. Its answer is then held for the ten + // minutes that signing in moves on, so a paused host is asked at most once for it either way, + // which is not the burst a pause exists to stop. + getViewer: interactive("getViewer", api.getViewer), listChangeRequests: wrap("listChangeRequests", api.listChangeRequests), ...(api.listChangeRequestsAcross === undefined ? {} @@ -847,19 +841,16 @@ export const make = Effect.gen(function* () { const viewersByHost = new Map(); const viewerFlights = yield* Cache.makeWith( (key: string): Effect.Effect => { - const [host, kind, roots, interactive] = JSON.parse(key) as [ + const [host, kind, roots] = JSON.parse(key) as [ string, SourceControlProviderKind, ReadonlyArray, - boolean, ]; const registered = registry.get(kind); if (registered === null) { return Effect.die(new Error(`Missing pull request provider: ${kind}`)); } - const api = withRateLimitBackoff(registered, host, rateLimits, { - interactiveViewer: interactive, - }); + const api = withRateLimitBackoff(registered, host, rateLimits); return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe( Effect.map((viewer) => ({ host, @@ -892,8 +883,6 @@ export const make = Effect.gen(function* () { const resolveViewers = ( projects: ReadonlyArray, viewerRoots: WorkspaceProjects["viewerRoots"], - /** Whether the reader is waiting on what this lookup stands in front of. */ - interactive = false, ) => Effect.forEach( [...new Set(projects.map(({ host }) => host))], @@ -909,7 +898,10 @@ 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); - const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort(), interactive]); + // Nothing about the caller is in the key. A listing and a press for the same host and + // roots are the same lookup, and putting them on separate flights would spawn two of + // this host's CLIs on a cold page load, which is the coalescing this exists for. + const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]); return Cache.get(viewerFlights, key); }), { concurrency: REPOSITORY_CONCURRENCY }, @@ -1599,7 +1591,7 @@ export const make = Effect.gen(function* () { project: SupportedProject, operation: string, ): Effect.Effect => - resolveViewers([project], new Map(), true).pipe( + resolveViewers([project], new Map()).pipe( Effect.flatMap(([resolved]) => { const error = resolved?.error ?? null; return error === null diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts index 7ccf9e724c16..66dfc7d45a4e 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts @@ -310,6 +310,25 @@ describe("azureDevOpsFilePatch", () => { expect(patch.edits).toBe(0); }); + it("lists a file whose hunks outweigh its sides without them", () => { + // A handful of very long lines is a few edits and nowhere near the edit ceiling, and the + // patch carries both sides in full with three lines of context around each hunk, so the + // section comes out heavier than either side was. What one file weighs is what a slice's + // budget is spent in, so the edit ceiling alone does not bound this. + const line = `${"a".repeat(400 * 1024)}\n`; + const patch = azureDevOpsFilePatch({ + change: change({ path: "min.js", oldPath: "min.js" }), + texts: texts(line, `${"b".repeat(400 * 1024)}\n`), + }); + + expect(patch.edits).toBeLessThan(MAX_FILE_DIFF_EDITS); + expect(patch.truncated).toBe(true); + expect(patch.section).toBe( + ["diff --git a/min.js b/min.js", "--- a/min.js", "+++ b/min.js", ""].join("\n"), + ); + expect(byteLength(patch.section)).toBeLessThan(byteLength(line)); + }); + it("marks a file that does not end in a newline, as git does", () => { const patch = azureDevOpsFilePatch({ change: change(), diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts index 05d27350933f..9218740f330f 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -88,18 +88,23 @@ const PATCH_CONTEXT_LINES = 3; * client with it, while it works out a patch of tens of thousands of lines nobody reads. Bounded in * edits rather than in milliseconds so a change slices the same way on every machine. * - * Measured at up to about 175ms for a pair at the size ceiling that shares no line at all, and at - * about 800ms for the same pair on a slower machine, which is the longest this can hold the thread - * for one file. + * Measured over nine runs on a pair at the size ceiling that shares no line at all: 359ms at + * best, 438ms typical, 1223ms at worst. The dearest input this ceiling still admits, at 1998 + * edits, was seen once at 2251ms on a loaded machine, and that is the longest one file can hold + * the thread for. */ export const MAX_FILE_DIFF_EDITS = 2_000; /** - * A backstop for a machine slower than any the edit ceiling was measured on. It sits several times - * above what that ceiling costs, because a timeout within reach of it would decide the shape of a - * patch by how fast the machine is: the same change would slice one way here and another on a + * A backstop for a machine slower than any the edit ceiling was measured on, and the reason the + * ceiling rather than this is what decides a patch's shape: a timeout that fires decides that + * shape by how fast the machine is, so the same change would slice one way here and another on a * busier host, and a file the ceiling admits would lose its hunks on the slower of the two. - * Nothing within the ceiling comes near this, so it changes no patch. + * + * Headroom over the ceiling is about twice its typical cost rather than the several times it + * would take to put this out of reach, and the dearest input the ceiling admits has been seen + * past this value under load. It holds in practice: thirty runs of that input lost no hunks here, + * against nineteen of thirty at 500ms. */ const MAX_FILE_DIFF_MILLIS = 2_000; @@ -116,6 +121,15 @@ export const MAX_DIFF_SLICE_EDITS = 6_000; */ export const MAX_DIFF_SLICE_BYTES = 256 * 1024; +/** + * How many files one slice carries however little each one weighs. A binary, oversize, purely + * renamed or unreadable entry is a header and nothing else, a couple of hundred bytes with no + * edits at all, so neither budget above stops a run of them until well over a thousand have piled + * up and the request has spent two reads on each. A change of vendored or generated assets is + * exactly that shape, and a listing may hold ten thousand entries of it. + */ +export const MAX_DIFF_SLICE_FILES = 300; + /** Git's own note for a side whose last line has no newline after it. */ const NO_NEWLINE_MARKER = "\\ No newline at end of file"; @@ -266,12 +280,17 @@ export function azureDevOpsFilePatch(input: { }); // A pure rename has no hunks to give. It is still listed, because dropping it would take the // file out of the change altogether. - return { - section: hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`, - truncated: false, - abandoned: false, - edits, - }; + const section = hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`; + // The edit ceiling bounds how far apart the two sides are, not what the hunks around them + // weigh: a pair of very long lines is a handful of edits and carries both sides in full, and + // three lines of context on each side of every hunk pull in more again. So a file well inside + // the ceiling can still come out heavier than either side was, and what one file weighs is what + // a slice's budget is spent in. Bounded here the same way the wholly-replaced path above is, + // and listed without its hunks rather than dropped. + if (byteLength(section) > MAX_FILE_BYTES) { + return { section: `${header}\n`, truncated: true, abandoned: false, edits }; + } + return { section, truncated: false, abandoned: false, edits }; } /** diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index 2ba54147376a..b4c35be62302 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -225,10 +225,11 @@ export const make = (dependencies: Dependencies) => { ): Effect.Effect => Effect.gen(function* () { const viewer = yield* requiredViewerOf(project, "filesViewed"); - const marks = yield* filesViewedStore + const held = yield* filesViewedStore .list(filesViewedScope(project, ref.number, viewer)) .pipe(Effect.mapError(toFilesViewedStoreError("filesViewed"))); - if (marks.length === 0) return { files: [], truncated: false }; + const marks = held.files; + if (marks.length === 0) return { files: [], truncated: held.truncated }; // A host that will not say what its head has of a file costs the marks their staleness, // which is what `fileRevisionsOf` answers null for, rather than costing the reader every // tick they have made. Who the reader is, above, cannot give way like that: these rows are @@ -263,8 +264,10 @@ export const make = (dependencies: Dependencies) => { : ("dismissed" as const), }; }), - // Every mark is a row this environment holds, so there is no page to run out of. - truncated: false, + // The store carries a bounded number of marks per scope, so a reader who has ticked more + // than that is short of some of them and told so, the same as a host-kept read that ran + // out of pages. + truncated: held.truncated, }; }); diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index b6583723b064..d6131c2a8a43 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -295,4 +295,31 @@ describe("naming the file a tick belongs to", () => { }), ).toThrow(); }); + + it("refuses a batch larger than a reader can press", () => { + // Every element of a batch is a statement of its own inside one transaction on an + // environment-kept host, or a field of its own in one GraphQL document on GitHub, so what a + // client may send has to be bounded rather than trusted to be a burst of presses. + const press = (path: string) => ({ path, viewed: true }); + const batch = (count: number) => ({ + projectId: "p1", + repository: "group/project", + number: 7, + files: Array.from({ length: count }, (_, at) => press(`src/f${at}.ts`)), + }); + + expect(() => decodeSetFilesViewed(batch(500))).not.toThrow(); + expect(() => decodeSetFilesViewed(batch(501))).toThrow(); + }); + + it("refuses a path far longer than any real one", () => { + expect(() => + decodeSetFilesViewed({ + projectId: "p1", + repository: "group/project", + number: 7, + files: [{ path: `src/${"a".repeat(4096)}.ts`, viewed: true }], + }), + ).toThrow(); + }); }); diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index bd85bfa26f36..9a02bb802faa 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -943,7 +943,13 @@ export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileConten // Not trimmed: a leading or trailing space is a legal part of a file's name, and both the patch // and the environment's own record of what a reader cleared are keyed by the name the host gave. // Trimming it here files the mark under a name nothing else uses, so the tick never comes back. -const FilePath = Schema.String.check(Schema.isNonEmpty()); +/** + * Bounded because a path arrives from a client rather than from the host: unbounded, one element + * of a write batch could carry a megabyte into a SQL statement or a GraphQL field. Far past any + * real path, and short of anything worth holding. + */ +const MAX_FILE_PATH_LENGTH = 4096; +const FilePath = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(MAX_FILE_PATH_LENGTH)); /** * Where one file of a change request stands with the person reading it. @@ -980,6 +986,14 @@ export type PullRequestFilesViewedResult = typeof PullRequestFilesViewedResult.T * Files to clear, or to put back. Several at once because a reader working down a diff ticks * boxes far faster than a host answers, so a burst is gathered into one request. */ +/** + * How many presses one write carries. A burst is what a reader ticked in the last few hundred + * milliseconds, and every element of it is a statement of its own inside one transaction here, or + * a field of its own in one GraphQL document on GitHub. Matched to what a read of the marks + * carries, so a client cannot write more of them than it can ever read back. + */ +const MAX_FILES_VIEWED_PRESSES = 500; + export const PullRequestSetFilesViewedInput = Schema.Struct({ ...PullRequestRef.fields, files: Schema.Array( @@ -987,7 +1001,7 @@ export const PullRequestSetFilesViewedInput = Schema.Struct({ path: FilePath, viewed: Schema.Boolean, }), - ), + ).check(Schema.isMaxLength(MAX_FILES_VIEWED_PRESSES)), }); export type PullRequestSetFilesViewedInput = typeof PullRequestSetFilesViewedInput.Type; From cf362b8709e1994d411ac130b32c8d737494d454 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 22:01:44 -0400 Subject: [PATCH 54/78] fix(server): keep what a reader has open out of reach of cold reads A held entry that no read renews is dropped by a listing walking past it, and one that no press bounds grows for as long as the review does. A file already known to be too heavy to send is not worth building to learn it. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestProvider.test.ts | 44 +++++++++++++- .../AzureDevOpsPullRequestProvider.ts | 15 +++-- .../pullRequest/PullRequestService.test.ts | 60 +++++++++++++++++++ .../server/src/pullRequest/azureDevOpsDiff.ts | 13 +++- .../src/pullRequest/pullRequestViewedFiles.ts | 28 ++++++++- 5 files changed, 151 insertions(+), 9 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts index 5b1b502d141d..cbe53f2cb5ee 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts @@ -3,7 +3,11 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; -import { make, MAX_DIFF_SPAWNS } from "./AzureDevOpsPullRequestProvider.ts"; +import { + LOCATION_CACHE_CAPACITY, + make, + MAX_DIFF_SPAWNS, +} from "./AzureDevOpsPullRequestProvider.ts"; import { byteLength, MAX_DIFF_SLICE_BYTES, @@ -315,4 +319,42 @@ describe("what one diff slice spends", () => { expect(read.slice.nextCursor).toBe(`${ITERATION.id}:4`); }), ); + it.effect("keeps the pull request being read, not the one looked up first", () => + Effect.gen(function* () { + // Where a pull request lives is read from the pull request itself, so an evicted entry + // costs a whole pull request read before any file can be asked for. Ordered by insertion + // alone a hit does not renew its entry, so the review being worked through is the first + // thing dropped once a listing has walked a cache's worth of cold pull requests. + const HOT = 7; + const readsOf = new Map(); + + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: (input) => + Effect.sync(() => { + readsOf.set(input.number, (readsOf.get(input.number) ?? 0) + 1); + return { ...PULL_REQUEST, number: input.number }; + }), + listIterations: () => Effect.succeed([ITERATION]), + listIterationChanges: () => + Effect.succeed({ changes: [change("a.ts")], truncated: false }), + readItemContent: () => Effect.succeed({ contents: side("new", 2, 4), isBinary: false }), + }), + ), + ); + + const readDiff = (number: number) => + provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number }); + + yield* readDiff(HOT); + // A cache's worth of cold pull requests, with the open one read in between each of them. + for (let filled = 0; filled < LOCATION_CACHE_CAPACITY; filled += 1) { + yield* readDiff(HOT + 1 + filled); + yield* readDiff(HOT); + } + + expect(readsOf.get(HOT)).toBe(1); + }), + ); }); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 9a42c42025ce..e2dc7efeb125 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -49,6 +49,9 @@ const DIFF_FILE_CONCURRENCY = 4; */ export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY; +/** How many pull requests' repository locations one provider remembers at once. */ +export const LOCATION_CACHE_CAPACITY = 128; + const CAPABILITIES: PullRequestCapabilities = { // Azure serves no patch of its own, so the one the Code tab reads is built here out of the // files an iteration changed and both sides of each of them. @@ -180,16 +183,20 @@ export const make = Effect.gen(function* () { * repositories, so it is remembered rather than re-read: the marks alone would otherwise pay for * a whole pull request read every time they checked whether a file had been pushed to. * - * Bounded and oldest-first, since a long-lived server sees far more pull requests than a reader - * ever has open. + * Bounded and least recently used, since a long-lived server sees far more pull requests than a + * reader ever has open: first in would let a listing walking cold pull requests evict the one + * being read, and every mark on it would then pay a whole pull request read again. */ - const LOCATION_CACHE_CAPACITY = 128; const locations = new Map(); const locationOf = (input: { readonly cwd: string; readonly number: number }) => { const key = `${input.cwd} ${input.number}`; const held = locations.get(key); - if (held !== undefined) return Effect.succeed(held); + if (held !== undefined) { + locations.delete(key); + locations.set(key, held); + return Effect.succeed(held); + } return cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( Effect.map((pullRequest) => { const location = pullRequest.location; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 615dcc3f906f..94507871f787 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -30,6 +30,10 @@ import { import { PullRequestProviderRegistry, fromProviders } from "./PullRequestProviderRegistry.ts"; import * as PullRequestService from "./PullRequestService.ts"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; +import { + FILE_REVISIONS_CACHE_CAPACITY, + MAX_FILE_REVISION_PATHS, +} from "./pullRequestViewedFiles.ts"; function project(input: { readonly id: string; @@ -5202,6 +5206,62 @@ it.effect("keeps environment marks apart from another change request's", () => }), ); +it.effect("bounds the paths one change request's held revisions carry", () => + Effect.gen(function* () { + // The cache's count bounds how many change requests are held, not what any one of them holds: + // a reader ticking a wide change request renews the same entry on every press and adds a path + // to it each time. A press carries at most half the cap, so going one past it takes three. + const asked: Array> = []; + const service = yield* environmentViewedService(new Map(), asked); + const batch = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => ({ + path: `${prefix}/${String(index).padStart(4, "0")}.ts`, + viewed: true, + })); + const press = (prefix: string, count: number) => + service.setFilesViewed({ ...GITLAB_REFERENCE, files: batch(prefix, count) }); + + yield* press("a", MAX_FILE_REVISION_PATHS / 2); + yield* press("b", MAX_FILE_REVISION_PATHS / 2); + yield* press("c", 1); + const pressed = asked.length; + + // The marks a read carries come first by path, so this one covers the earliest batch, which + // is where the paths asked about longest ago are. Held short of them the entry no longer + // answers the read, and the host is asked rather than the reader being told a version that + // nothing holds any more. + yield* service.filesViewed(GITLAB_REFERENCE); + + assert.strictEqual(asked.length, pressed + 1); + assert.ok(asked.at(-1)?.includes("a/0000.ts")); + }), +); + +it.effect("keeps the change request being ticked through, not the one pressed first", () => + Effect.gen(function* () { + // Ordered by insertion alone a hit does not renew its entry, so the review a reader is + // working down is the first thing dropped once a cache's worth of other change requests have + // been pressed, and the next press on it pays a host read for a version already held. + const asked: Array> = []; + const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); + const press = (number: number) => + service.setFilesViewed({ + ...GITLAB_REFERENCE, + number, + files: [{ path: "src/a.ts", viewed: true }], + }); + + yield* press(1); + // A cache's worth of other change requests, with the open one pressed in between each. + for (let filled = 0; filled < FILE_REVISIONS_CACHE_CAPACITY; filled += 1) { + yield* press(2 + filled); + yield* press(1); + } + + assert.strictEqual(asked.length, 1 + FILE_REVISIONS_CACHE_CAPACITY); + }), +); + /** The environment-backed fixture with its own answer to who the reader is. */ const environmentViewedServiceWithViewer = ( revisions: Map, diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts index 9218740f330f..c1e7a9206346 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -229,11 +229,20 @@ export function azureDevOpsFilePatch(input: { const created = oldContents === "" && newContents !== ""; const deleted = newContents === "" && oldContents !== ""; if (created || deleted) { - const lines = contentLines(created ? newContents : oldContents); - const section = replacementSection(header, input.texts); + const contents = created ? newContents : oldContents; + const lines = contentLines(contents); // A marker on every line puts a side that just fits the size ceiling half again over it, and // what one file weighs is what a slice's budget is spent in. Such a file is listed without its // hunks, the same as one whose sides were too big to read at all. + // + // Weighed off the side's own bytes plus the one marker a line will carry, which is strictly + // under what the section costs and needs none of it built. Joining and measuring half a + // megabyte of lines to learn an answer already known is 15 to 120ms, and it is spent on + // exactly the files that hold the request longest. + if (byteLength(contents) + lines.length > MAX_FILE_BYTES) { + return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; + } + const section = replacementSection(header, input.texts); if (byteLength(section) > MAX_FILE_BYTES) { return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; } diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index b4c35be62302..ad4348f48cd0 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -27,7 +27,16 @@ import type { PullRequestError, SupportedProject } from "./PullRequestService.ts */ const FILE_REVISIONS_CACHE_TTL = Duration.seconds(60); const FILE_REVISIONS_STALE_WINDOW = Duration.minutes(10); -const FILE_REVISIONS_CACHE_CAPACITY = 64; +export const FILE_REVISIONS_CACHE_CAPACITY = 64; + +/** + * How many paths one scope's entry carries. The count above bounds how many scopes are held, not + * what any one of them holds: a reader ticking one file after another renews the same scope on + * every press and adds a path to it each time, so a long review of a wide change request grows a + * single entry without limit. Well over what a scope can report marks for, so a trim here only + * ever reaches paths carried from earlier presses. + */ +export const MAX_FILE_REVISION_PATHS = 1_000; interface FileRevisionsDependencies { readonly runFork: (effect: Effect.Effect) => unknown; @@ -85,12 +94,23 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { const revisions = new Map(carried?.revisions ?? []); const asked = new Set(carried?.asked ?? []); for (const path of paths) { + // Reinserted rather than added, so what a full entry drops below is the path nobody has + // asked about in the longest rather than one just asked for. + asked.delete(path); asked.add(path); const revision = answer.get(path); // Left out of the answer is the host not saying, not the head having nothing: the // version it last gave stands, since deleting it would turn a file reported as changed // back into a cleared one. - if (revision !== undefined) revisions.set(path, revision); + if (revision !== undefined) { + revisions.delete(path); + revisions.set(path, revision); + } + } + for (const path of asked) { + if (asked.size <= MAX_FILE_REVISION_PATHS) break; + asked.delete(path); + revisions.delete(path); } heldFileRevisions.delete(key); if (heldFileRevisions.size >= FILE_REVISIONS_CACHE_CAPACITY) { @@ -111,6 +131,10 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { const heldFileRevisionsFor = (key: string, paths: ReadonlyArray, now: number) => { const held = heldFileRevisions.get(key); if (held === undefined) return null; + // Put back at the end on every read, so the scope a reader is working through is not the one + // evicted by a listing walking scopes nobody has open. + heldFileRevisions.delete(key); + heldFileRevisions.set(key, held); if (now - held.at > Duration.toMillis(FILE_REVISIONS_STALE_WINDOW)) return null; return paths.every((path) => held.asked.has(path)) ? held : null; }; From fac056be7711adc03e309c9380e4747894b94feb Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 9 Sep 2026 22:52:51 -0400 Subject: [PATCH 55/78] test(server): cover what a live azure organisation answers with The invoke envelope and the fields a real repository leaves out were only ever asserted against mocked output, so a change in either would have reached readers before a test. Signed-off-by: Yordis Prieto --- .../azureDevOpsPullRequestJson.test.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts index 9b707662cb27..8e863b8751d9 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts @@ -549,3 +549,104 @@ describe("decodeItemContentJson", () => { ).toEqual({ contents: "b2xk", isBinary: true }); }); }); + +/** + * Every other fixture in this file is written from Azure's published contract. These are the + * shapes a real organisation answered with, read back through `az devops invoke` rather than + * `az rest`: the extension hands over the route's own body with one key of its own added, and a + * live repository leaves out fields the contract documents. + */ +describe("what az devops invoke answers with", () => { + it("reads the envelope the extension adds its own continuation token to", () => { + // Set on every JSON body it returns, from a response header these routes do not send, so it + // arrives as null rather than not at all. Nothing reads it, and it must not fail the decode. + expect( + expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))), + ).toEqual([]); + + expect( + expectSuccess( + decodeIterationsJson( + asJson({ + count: 1, + continuation_token: null, + value: [ + { + id: 1, + sourceRefCommit: { commitId: "f4031105213c68f197cd46ea303bb5e72acc889a" }, + commonRefCommit: { commitId: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35" }, + }, + ], + }), + ), + ), + ).toEqual([ + { + id: 1, + headCommit: "f4031105213c68f197cd46ea303bb5e72acc889a", + mergeBaseCommit: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35", + }, + ]); + }); + + it("keeps the files of a change that names neither their object type nor their page after it", () => { + // A live iteration states `changeType`, `item.path` and `item.objectId` and nothing else: no + // `gitObjectType`, no `isFolder`, and no `nextSkip` on the only page. Each of those absences + // is what the defaults in the decoder are for, and reading any of them as stated would drop + // every file of every Azure change request. + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + continuation_token: null, + changeEntries: [ + { changeType: "add", item: { path: "/DEMO.md", objectId: "EC005DB24" } }, + { changeType: "edit", item: { path: "/README.md", objectId: "8F8047A49" } }, + ], + }), + ), + ); + + expect(page.nextSkip).toBeNull(); + expect(page.changes).toEqual([ + { + path: "DEMO.md", + oldPath: "DEMO.md", + changeKind: "new", + objectId: "EC005DB24", + originalObjectId: null, + }, + { + path: "README.md", + oldPath: "README.md", + changeKind: "change", + objectId: "8F8047A49", + originalObjectId: null, + }, + ]); + }); + + it("reads a text file whose content type Azure calls a stream", () => { + // `contentMetadata` comes back for a markdown file with `application/octet-stream` on it and + // no `isBinary` at all, so the content type is not the field to ask, and its absence is the + // answer that the text is text. + expect( + expectSuccess( + decodeItemContentJson( + asJson({ + path: "/README.md", + objectId: "8f8047a49", + gitObjectType: "blob", + content: "# T3Demo\n", + contentMetadata: { + contentType: "application/octet-stream", + encoding: 65001, + extension: "md", + fileName: "README.md", + }, + continuation_token: null, + }), + ), + ), + ).toEqual({ contents: "# T3Demo\n", isBinary: false }); + }); +}); From 87aa7cc196cfc067fb0049e5ad1961ca66ac152a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 11 Sep 2026 09:33:55 -0400 Subject: [PATCH 56/78] fix: keep a viewed mark on the file the host named A name holding a tab or a newline reached the clients as the part of itself before that byte, so the mark, the review comment and the file read all went to a path no host has ever heard of. Signed-off-by: Yordis Prieto --- .../mobile/src/features/review/reviewModel.ts | 13 +- .../src/pullRequest/azureDevOpsDiff.test.ts | 83 +++++++++ .../server/src/pullRequest/azureDevOpsDiff.ts | 27 ++- .../src/pullRequest/bitbucketDiffRevisions.ts | 79 +-------- apps/web/src/lib/diffRendering.test.ts | 61 +++++++ apps/web/src/lib/diffRendering.ts | 27 +-- packages/shared/package.json | 4 + packages/shared/src/gitPatchPath.test.ts | 97 +++++++++++ packages/shared/src/gitPatchPath.ts | 158 ++++++++++++++++++ 9 files changed, 458 insertions(+), 91 deletions(-) create mode 100644 packages/shared/src/gitPatchPath.test.ts create mode 100644 packages/shared/src/gitPatchPath.ts diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 202157b837cc..f3adee73382b 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -1,6 +1,7 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts"; +import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import * as Order from "effect/Order"; @@ -126,14 +127,20 @@ function gitSubtitle(section: ReviewDiffPreviewSource): string | null { return "Base branch unavailable"; } +/** + * The file's own name, given the patch wrote it the way git writes one: a name holding a tab, a + * newline, a quote or a backslash arrives quoted and escaped, and the parser hands one of those + * back still escaped. + */ function stripGitPrefix(pathValue: string | undefined): string | null { if (!pathValue) { return null; } - if (pathValue.startsWith("a/") || pathValue.startsWith("b/")) { - return pathValue.slice(2); + const named = unquoteGitPatchPath(pathValue); + if (named.startsWith("a/") || named.startsWith("b/")) { + return named.slice(2); } - return pathValue; + return named; } function stripTrailingNewline(value: string): string { diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts index 66dfc7d45a4e..30d575755199 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts @@ -1,3 +1,4 @@ +import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import { describe, expect, it } from "vite-plus/test"; import { @@ -350,6 +351,88 @@ describe("azureDevOpsUnreadableFilePatch", () => { }); }); +describe("a file Azure names something a patch header cannot carry plainly", () => { + it("writes each side as git's quoted form, the side letter inside the quotes", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "notes\treadme.md", oldPath: "notes\treadme.md" }), + texts: texts("one\n", "two\n"), + }); + + expect(patch.section).toBe( + [ + 'diff --git "a/notes\\treadme.md" "b/notes\\treadme.md"', + '--- "a/notes\\treadme.md"', + '+++ "b/notes\\treadme.md"', + "@@ -1 +1 @@", + "-one", + "+two", + "", + ].join("\n"), + ); + }); + + it("keeps a name holding a newline on the one header line it belongs to", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "line\nfile.txt", oldPath: "line\nfile.txt" }), + texts: texts("one\n", "two\n"), + }); + + // Written as itself the name would start a line of its own, and a reader would take what + // followed for a header the patch never had. + expect(patch.section.split("\n").slice(0, 3)).toEqual([ + 'diff --git "a/line\\nfile.txt" "b/line\\nfile.txt"', + '--- "a/line\\nfile.txt"', + '+++ "b/line\\nfile.txt"', + ]); + }); + + it("quotes the names a rename states, which carry no side letter", () => { + const patch = azureDevOpsFilePatch({ + change: change({ + path: "docs/new\tname.md", + oldPath: "docs/old\tname.md", + changeKind: "rename-pure", + }), + texts: texts("same\n", "same\n"), + }); + + expect(patch.section).toBe( + [ + 'diff --git "a/docs/old\\tname.md" "b/docs/new\\tname.md"', + 'rename from "docs/old\\tname.md"', + 'rename to "docs/new\\tname.md"', + '--- "a/docs/old\\tname.md"', + '+++ "b/docs/new\\tname.md"', + "", + ].join("\n"), + ); + }); + + it("quotes the sides of the one line a binary file gets", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "logo\tmark.png", oldPath: "logo\tmark.png" }), + texts: texts("PNG\u0000old", "PNG\u0000new"), + }); + + expect(patch.section).toContain( + 'Binary files "a/logo\\tmark.png" and "b/logo\\tmark.png" differ', + ); + }); + + it("hands a reader of the header back the name Azure gave", () => { + const path = 'every\t\n"kind"\\of.md'; + const patch = azureDevOpsFilePatch({ + change: change({ path, oldPath: path }), + texts: texts("one\n", "two\n"), + }); + const [header, oldLine, newLine] = patch.section.split("\n"); + + expect(header).not.toContain("\t"); + expect(unquoteGitPatchPath(oldLine?.slice(4) ?? "")).toBe(`a/${path}`); + expect(unquoteGitPatchPath(newLine?.slice(4) ?? "")).toBe(`b/${path}`); + }); +}); + describe("a diff cursor", () => { it("carries the push it was taken against back to the next slice", () => { const cursor = formatAzureDevOpsDiffCursor({ iterationId: 3, fileIndex: 12 }); diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts index c1e7a9206346..f44c6cd67084 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -1,3 +1,4 @@ +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import { structuredPatch } from "diff"; import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; @@ -165,17 +166,31 @@ function hunkRange(start: number, lines: number): string { /** * The `diff --git` preamble a viewer reads a file's identity and fate from. Azure reports no file * mode, so the ordinary one stands in, exactly as it does for the GitHub files API here. + * + * The names are written the way git writes them, quoted where the name holds anything a header + * cannot carry plainly. Azure names a file in JSON, where a tab or a newline is just another + * character, and a reader of the header takes the name to stop at the first of either: written as + * itself, such a file is read under a shorter name than it has, and the viewed mark a reader puts + * on it is put on a path the host has never heard of. + * + * A side's `a/` or `b/` goes inside the quoting, as git puts it, because the quoting is of the + * whole token the reader takes off the line. A rename states its names with no side to them. */ function patchHeader(change: AzureDevOpsChangeEntry): string { - const lines = [`diff --git a/${change.oldPath} b/${change.path}`]; + const oldSide = quoteGitPatchPath(`a/${change.oldPath}`); + const newSide = quoteGitPatchPath(`b/${change.path}`); + const lines = [`diff --git ${oldSide} ${newSide}`]; if (change.changeKind === "new") lines.push("new file mode 100644"); if (change.changeKind === "deleted") lines.push("deleted file mode 100644"); if (change.changeKind === "rename-pure" || change.changeKind === "rename-changed") { - lines.push(`rename from ${change.oldPath}`, `rename to ${change.path}`); + lines.push( + `rename from ${quoteGitPatchPath(change.oldPath)}`, + `rename to ${quoteGitPatchPath(change.path)}`, + ); } lines.push( - `--- ${change.changeKind === "new" ? "/dev/null" : `a/${change.oldPath}`}`, - `+++ ${change.changeKind === "deleted" ? "/dev/null" : `b/${change.path}`}`, + `--- ${change.changeKind === "new" ? "/dev/null" : oldSide}`, + `+++ ${change.changeKind === "deleted" ? "/dev/null" : newSide}`, ); return lines.join("\n"); } @@ -215,7 +230,9 @@ export function azureDevOpsFilePatch(input: { if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) { // Git's own wording for a file it will not spell out, which every diff viewer already reads. - const binary = `Binary files a/${input.change.oldPath} and b/${input.change.path} differ`; + const oldSide = quoteGitPatchPath(`a/${input.change.oldPath}`); + const newSide = quoteGitPatchPath(`b/${input.change.path}`); + const binary = `Binary files ${oldSide} and ${newSide} differ`; return { section: `${header}\n${binary}\n`, truncated: true, abandoned: false, edits: 0 }; } if (byteLength(oldContents) > MAX_FILE_BYTES || byteLength(newContents) > MAX_FILE_BYTES) { diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts index e5baf44c7a72..663da3bd35bd 100644 --- a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts +++ b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts @@ -1,21 +1,8 @@ +import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; + const ENTRY = "diff --git "; const QUOTE = '"'; -const NAMED_ESCAPES: Record = { - '"': 0x22, - "\\": 0x5c, - a: 0x07, - b: 0x08, - f: 0x0c, - n: 0x0a, - r: 0x0d, - t: 0x09, - v: 0x0b, -}; - -const utf8 = new TextEncoder(); -const fromUtf8 = new TextDecoder(); - interface Entry { oldPath: string | null; newPath: string | null; @@ -25,60 +12,6 @@ interface Entry { inBody: boolean; } -/** - * The name inside git's quoted form, which git reaches for when a name holds a tab, a newline, a - * quote, a backslash, or, under `core.quotePath`, any byte outside ASCII. - * - * The escapes are per byte, so a name in any other alphabet arrives as a run of octal and only - * reads back as itself once those bytes are rejoined and decoded together. - */ -function unquotePath(token: string): string { - if (token.length < 2 || !token.startsWith(QUOTE) || !token.endsWith(QUOTE)) return token; - const body = token.slice(1, -1); - const bytes: Array = []; - // Anything git left as itself is encoded a run at a time rather than a unit at a time, so a - // character outside the basic plane keeps its pair together rather than coming back as halves. - let literal = ""; - const flush = () => { - if (literal.length === 0) return; - bytes.push(...utf8.encode(literal)); - literal = ""; - }; - let at = 0; - while (at < body.length) { - const char = body.charAt(at); - if (char !== "\\") { - literal += char; - at += 1; - continue; - } - const escaped = body.charAt(at + 1); - if (escaped === "") { - flush(); - bytes.push(0x5c); - break; - } - const named = NAMED_ESCAPES[escaped]; - if (named !== undefined) { - flush(); - bytes.push(named); - at += 2; - continue; - } - const octal = body.slice(at + 1, at + 4); - if (/^[0-7]{3}$/.test(octal)) { - flush(); - bytes.push(Number.parseInt(octal, 8)); - at += 4; - continue; - } - literal += escaped; - at += 2; - } - flush(); - return fromUtf8.decode(new Uint8Array(bytes)); -} - /** Where a quoted name closes, given git escapes every quote the name itself holds. */ function quotedEnd(rest: string): number { for (let at = 1; at < rest.length; at += 1) { @@ -104,12 +37,12 @@ function sidePath(rest: string, prefix: string): string | null { const tab = rest.indexOf("\t"); const token = tab === -1 ? rest : rest.slice(0, tab); if (token === "/dev/null") return null; - const path = unquotePath(token); + const path = unquoteGitPatchPath(token); return path.startsWith(prefix) ? path.slice(prefix.length) : path; } function headerSide(token: string, prefix: string): string | null { - const path = unquotePath(token); + const path = unquoteGitPatchPath(token); return path.startsWith(prefix) ? path.slice(prefix.length) : null; } @@ -194,9 +127,9 @@ export function parseDiffFileRevisions(patch: string): ReadonlyMap { @@ -179,3 +181,62 @@ describe("getDiffLineStat", () => { expect(getDiffLineStat(parsed.files)).toEqual({ additions: 3, deletions: 2 }); }); }); + +describe("a file whose name a patch header cannot carry plainly", () => { + /** How git writes such a name, and so how every provider's patch arrives here. */ + const quotedPatch = (written: string) => + [ + `diff --git "a/${written}" "b/${written}"`, + "index 1111111..2222222 100644", + `--- "a/${written}"`, + `+++ "b/${written}"`, + "@@ -1 +1 @@", + "-before", + "+after", + "", + ].join("\n"); + + const pathOf = (patch: string) => { + const parsed = getRenderablePatch(patch, "review"); + expect(parsed?.kind).toBe("files"); + if (parsed?.kind !== "files") throw new Error("patch did not parse as files"); + const file = parsed.files[0]; + expect(file).toBeDefined(); + if (!file) throw new Error("patch carried no file"); + return resolveFileDiffPath(file); + }; + + it("is the name the host knows, not the part of it before the tab", () => { + // The path is what a viewed mark, a review comment and a file read are all asked for by, so a + // name read short is a mark put on a path the host has never heard of. + expect(pathOf(quotedPatch("tab\\tfile.txt"))).toBe("tab\tfile.txt"); + }); + + it("is the name the host knows, not the part of it before the newline", () => { + expect(pathOf(quotedPatch("line\\nfile.txt"))).toBe("line\nfile.txt"); + }); + + it("reads the octal a host with core.quotePath on writes for a name outside ASCII", () => { + expect(pathOf(quotedPatch("caf\\303\\251/r\\303\\251sum\\303\\251.ts"))).toBe("café/résumé.ts"); + }); + + it("reads both sides of a rename under the names they really have", () => { + const patch = [ + 'diff --git "a/old\\tname.ts" "b/new\\tname.ts"', + "similarity index 90%", + 'rename from "old\\tname.ts"', + 'rename to "new\\tname.ts"', + "", + ].join("\n"); + + const parsed = getRenderablePatch(patch, "review"); + expect(parsed?.kind).toBe("files"); + if (parsed?.kind !== "files") return; + const file = parsed.files[0]; + expect(file).toBeDefined(); + if (!file) return; + + expect(resolveFileDiffPath(file)).toBe("new\tname.ts"); + expect(resolveFileDiffPreviousPath(file)).toBe("old\tname.ts"); + }); +}); diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index 2866e88f45f6..d43ce512e3ef 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -1,5 +1,6 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { FileDiffMetadata } from "@pierre/diffs/types"; +import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; const DIFF_THEME_NAMES = { light: "pierre-light", @@ -143,12 +144,22 @@ export function getRenderablePatch( } } +/** + * What the patch called the file, as the file's own name. + * + * Git writes a name holding a tab, a newline, a quote or a backslash quoted and escaped, and the + * parser hands one of those back still escaped. The name is what the rest of the app keys a file + * by and what it says to the server about one: a viewed mark, a review comment and a file's + * contents are all asked for by this path, and the host knows the file only under the name it + * really has. + */ +function fileDiffPath(raw: string): string { + const named = unquoteGitPatchPath(raw); + return named.startsWith("a/") || named.startsWith("b/") ? named.slice(2) : named; +} + export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { - const raw = fileDiff.name ?? fileDiff.prevName ?? ""; - if (raw.startsWith("a/") || raw.startsWith("b/")) { - return raw.slice(2); - } - return raw; + return fileDiffPath(fileDiff.name ?? fileDiff.prevName ?? ""); } /** @@ -156,11 +167,7 @@ export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { * path, and the hosts that resolve a diff position against both sides need both names. */ export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string { - const raw = fileDiff.prevName ?? fileDiff.name ?? ""; - if (raw.startsWith("a/") || raw.startsWith("b/")) { - return raw.slice(2); - } - return raw; + return fileDiffPath(fileDiff.prevName ?? fileDiff.name ?? ""); } export function buildFileDiffIdentityKey(fileDiff: FileDiffMetadata): string { diff --git a/packages/shared/package.json b/packages/shared/package.json index b502090e2a9d..26e96e98da35 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -286,6 +286,10 @@ "./dateTime": { "types": "./src/dateTime.ts", "import": "./src/dateTime.ts" + }, + "./gitPatchPath": { + "types": "./src/gitPatchPath.ts", + "import": "./src/gitPatchPath.ts" } }, "scripts": { diff --git a/packages/shared/src/gitPatchPath.test.ts b/packages/shared/src/gitPatchPath.test.ts new file mode 100644 index 000000000000..20c5065729f8 --- /dev/null +++ b/packages/shared/src/gitPatchPath.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { quoteGitPatchPath, unquoteGitPatchPath } from "./gitPatchPath.ts"; + +const BELL = "\u0007"; +const UNIT_SEPARATOR = "\u001f"; +const DELETE = "\u007f"; + +describe("quoteGitPatchPath", () => { + it("leaves a name a header can carry as itself", () => { + expect(quoteGitPatchPath("src/app.ts")).toBe("src/app.ts"); + expect(quoteGitPatchPath("with space.txt")).toBe("with space.txt"); + // Outside ASCII is only quoted for what a terminal can show, and a diff viewer is not one. + expect(quoteGitPatchPath("café/résumé.ts")).toBe("café/résumé.ts"); + expect(quoteGitPatchPath("🚀.ts")).toBe("🚀.ts"); + }); + + it("writes git's escape for a character the header would read as its own", () => { + expect(quoteGitPatchPath("tab\tfile.txt")).toBe('"tab\\tfile.txt"'); + expect(quoteGitPatchPath("line\nfile.txt")).toBe('"line\\nfile.txt"'); + expect(quoteGitPatchPath('quo"te.txt')).toBe('"quo\\"te.txt"'); + expect(quoteGitPatchPath("back\\slash.txt")).toBe('"back\\\\slash.txt"'); + expect(quoteGitPatchPath(`ring${BELL}.txt`)).toBe('"ring\\a.txt"'); + }); + + it("writes the octal of a control character git has no name for", () => { + expect(quoteGitPatchPath(`unit${UNIT_SEPARATOR}.txt`)).toBe('"unit\\037.txt"'); + expect(quoteGitPatchPath(`del${DELETE}.txt`)).toBe('"del\\177.txt"'); + }); +}); + +describe("a name written into a header and read back out", () => { + const names = [ + "src/app.ts", + "with space.txt", + "café/résumé.ts", + "🚀.ts", + "tab\tfile.txt", + "line\nfile.txt", + "carriage\rfile.txt", + 'quo"te.txt', + "back\\slash.txt", + `ring${BELL}.txt`, + `unit${UNIT_SEPARATOR}.txt`, + `del${DELETE}.txt`, + `every\t\n\r"\\${BELL}.txt`, + ]; + + for (const name of names) { + it(`is the name that went in: ${JSON.stringify(name)}`, () => { + const written = quoteGitPatchPath(name); + expect(unquoteGitPatchPath(written)).toBe(name); + // A parser that takes the quotes off itself, as the clients' one does, gets there too. + const unwrapped = written.startsWith('"') ? written.slice(1, -1) : written; + expect(unquoteGitPatchPath(unwrapped)).toBe(name); + }); + } + + it("carries the whole name past the first thing a header stops at", () => { + const written = quoteGitPatchPath("tab\tfile.txt"); + + // The bug this guards: a header line ends at a tab and splits on a space, so the name written + // as itself would be read as `tab` and marked viewed under a path no host has. + expect(written).not.toContain("\t"); + expect(written).not.toContain("\n"); + expect(unquoteGitPatchPath(written)).not.toBe("tab"); + }); +}); + +describe("unquoteGitPatchPath", () => { + it("leaves a token git saw no need to quote alone", () => { + expect(unquoteGitPatchPath("src/app.ts")).toBe("src/app.ts"); + expect(unquoteGitPatchPath("with space.txt")).toBe("with space.txt"); + expect(unquoteGitPatchPath("")).toBe(""); + expect(unquoteGitPatchPath('"')).toBe('"'); + }); + + it("reads the octal escapes a host with core.quotePath on writes", () => { + expect(unquoteGitPatchPath('"caf\\303\\251/r\\303\\251sum\\303\\251.ts"')).toBe( + "café/résumé.ts", + ); + expect(unquoteGitPatchPath('"\\360\\237\\232\\200.ts"')).toBe("🚀.ts"); + }); +}); + +describe("a name a parser handed back with its quotes already off", () => { + it("is read for the escapes it still carries", () => { + expect(unquoteGitPatchPath("tab\\tfile.txt")).toBe("tab\tfile.txt"); + expect(unquoteGitPatchPath("caf\\303\\251.ts")).toBe("café.ts"); + }); + + it("reads an escape git would never write the way C reads it", () => { + expect(unquoteGitPatchPath("odd\\zname.txt")).toBe("oddzname.txt"); + expect(unquoteGitPatchPath("trailing\\")).toBe("trailing\\"); + expect(unquoteGitPatchPath("short\\12.txt")).toBe("short12.txt"); + }); +}); diff --git a/packages/shared/src/gitPatchPath.ts b/packages/shared/src/gitPatchPath.ts new file mode 100644 index 000000000000..4141f7deec73 --- /dev/null +++ b/packages/shared/src/gitPatchPath.ts @@ -0,0 +1,158 @@ +/** + * How a file's name travels in a unified patch, which is not as itself. + * + * A patch's headers are lines with the name inside them, and the reader finds the name by where + * it stops: `diff --git a/ b/` splits on a space, `--- a/` stops at a tab. So a + * name holding a tab or a newline reads back as the part of itself before that byte, or fabricates + * a header line of its own. Git's answer is to write such a name quoted, with C-style escapes, and + * every reader of the format knows the form. + * + * Both halves live together so that what one writes is what the other reads. + */ + +/** + * What git escapes by name, as the character written and the escape written for it. + * + * Not every byte git would escape: it also escapes anything outside ASCII when `core.quotePath` is + * on, which is a setting for what a terminal can show rather than anything the format needs. A + * patch here is read by a diff viewer, so a name in another alphabet is left as itself and arrives + * legible. + */ +const ESCAPE_BY_CHARACTER = new Map([ + ['"', '\\"'], + ["\\", "\\\\"], + ["\u0007", "\\a"], + ["\b", "\\b"], + ["\t", "\\t"], + ["\n", "\\n"], + ["\v", "\\v"], + ["\f", "\\f"], + ["\r", "\\r"], +]); + +/** The escape written for a character, as the byte it stands for. */ +const CHARACTER_BY_ESCAPE: Record = { + '"': 0x22, + "\\": 0x5c, + a: 0x07, + b: 0x08, + f: 0x0c, + n: 0x0a, + r: 0x0d, + t: 0x09, + v: 0x0b, +}; + +const QUOTE = '"'; +const DELETE_CHARACTER = 0x7f; +const LOWEST_PRINTABLE = 0x20; +const BACKSLASH = 0x5c; + +const utf8 = new TextEncoder(); +const fromUtf8 = new TextDecoder(); + +/** + * A name as a patch header can carry it: itself where that is unambiguous, and git's quoted form + * where it is not. The name a reader of the header gets back is the name that went in. + * + * A quote or a backslash is what the quoting is written with, and a control character either stops + * the reader short or starts a line the patch never had, so a name holding any of them is quoted. + * + * A header side's `a/` or `b/` belongs inside the quoting, so pass it in along with the name: what + * git quotes is the whole token a reader takes off the line, side letter and all. + */ +export function quoteGitPatchPath(path: string): string { + let body = ""; + let quoting = false; + for (const character of path) { + const escape = ESCAPE_BY_CHARACTER.get(character); + if (escape !== undefined) { + body += escape; + quoting = true; + continue; + } + const code = character.codePointAt(0) ?? 0; + // Every character git has no name for is written as the octal of its byte, and a control + // character is one byte in UTF-8, so the character's own code point is that byte. + if (code < LOWEST_PRINTABLE || code === DELETE_CHARACTER) { + body += `\\${code.toString(8).padStart(3, "0")}`; + quoting = true; + continue; + } + body += character; + } + return quoting ? `${QUOTE}${body}${QUOTE}` : path; +} + +/** + * The escapes inside a quoted form undone, whether or not the quotes are still around them. + * + * A name holding no backslash at all is already itself and is handed straight back, which is what + * keeps an unquoted name out of this: git quotes any name with a backslash in it, so a name that + * arrived unquoted has no escape to undo. An escape git would never write reads the way C reads + * it, as the character behind the backslash. + * + * The escapes are per byte, so a name in any other alphabet arrives as a run of octal and only + * reads back as itself once those bytes are rejoined and decoded together. + */ +function unescapeBody(body: string): string { + if (!body.includes("\\")) return body; + const bytes: Array = []; + // Anything left as itself is encoded a run at a time rather than a unit at a time, so a + // character outside the basic plane keeps its pair together rather than coming back as halves. + let literal = ""; + const flush = () => { + if (literal.length === 0) return; + bytes.push(...utf8.encode(literal)); + literal = ""; + }; + let at = 0; + while (at < body.length) { + const character = body.charAt(at); + if (character !== "\\") { + literal += character; + at += 1; + continue; + } + const escaped = body.charAt(at + 1); + if (escaped === "") { + flush(); + bytes.push(BACKSLASH); + break; + } + const named = CHARACTER_BY_ESCAPE[escaped]; + if (named !== undefined) { + flush(); + bytes.push(named); + at += 2; + continue; + } + const octal = body.slice(at + 1, at + 4); + if (/^[0-7]{3}$/.test(octal)) { + flush(); + bytes.push(Number.parseInt(octal, 8)); + at += 4; + continue; + } + literal += escaped; + at += 2; + } + flush(); + return fromUtf8.decode(new Uint8Array(bytes)); +} + +/** + * One header's name token as the name it stands for. + * + * The quoting comes off where it is there, and the escapes are undone either way: patch parsers + * disagree about how much of the quoting they hand back, and the one the clients read diffs with + * takes the quotes off the `diff --git` line's names and leaves them on a rename's. A name a + * producer wrote unquoted never carries an escape to undo, so reading it for them costs it + * nothing. + */ +export function unquoteGitPatchPath(token: string): string { + if (token.length >= 2 && token.startsWith(QUOTE) && token.endsWith(QUOTE)) { + return unescapeBody(token.slice(1, -1)); + } + return unescapeBody(token); +} From 4c404a84709b8b7e6ff824468cad704f735351ed Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 12 Sep 2026 14:54:02 -0400 Subject: [PATCH 57/78] fix(server): hold background viewer lookups behind a rate-limit pause A failed viewer lookup is held nowhere, so letting every read bypass a host's pause meant each list refresh and detail read spawned its CLI again and re-extended the backoff. Only the press the reader is waiting on needs an answer through a pause. Signed-off-by: Yordis Prieto --- .../pullRequest/PullRequestService.test.ts | 77 ++++++++++++++++--- .../src/pullRequest/PullRequestService.ts | 49 +++++++++--- 2 files changed, 104 insertions(+), 22 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 94507871f787..d40cdabda2e9 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1527,20 +1527,10 @@ it.effect("uses a manual rate limit to pause later reads", () => action: "close", }), ); - const paused = yield* service.list({ state: "open", involvement: "all" }); + const error = yield* Effect.flip(service.list({ state: "open", involvement: "all" })); - // The listing itself never reaches the host, and the repository behind it is reported as one - // that could not be read. assert.strictEqual(listCalls, 0); - assert.deepStrictEqual(paused.entries, []); - assert.deepStrictEqual( - paused.errors.map((error) => error.projectId), - ["p1"], - ); - // Who is signed in is not what a pause holds back. It is asked once per host per ten minutes - // and it stands in front of everything else here, so refusing it would report a host that is - // merely backing off as one nobody is signed in to. - assert.strictEqual(paused.viewers["github.com"], "bilal"); + assert.strictEqual(error._tag, "PullRequestOperationError"); }), ); @@ -5426,6 +5416,69 @@ it.effect("records a press while the host is backing off", () => }), ); +it.effect("asks who is reading through a pause only for the press that is waiting on it", () => + Effect.gen(function* () { + let viewerLookups = 0; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + { + ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), + getViewer: () => + Effect.sync(() => { + viewerLookups += 1; + return "bilal"; + }), + listChangeRequests: () => + Effect.succeed({ items: [], truncated: false, continues: true }), + // Backing off for the hour, so the pause outlives the ten minutes who is signed in is + // held for. + getFileRevisions: () => + Effect.fail( + new PullRequestProviderError({ + provider: "gitlab", + operation: "getFileRevisions", + reason: "rate-limited", + detail: "API rate limit exceeded.", + retryAt: 60 * 60 * 1_000, + }), + ), + }, + ], + }); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + assert.strictEqual(viewerLookups, 1); + yield* TestClock.adjust("11 minutes"); + + // A listing is not the reader waiting on this lookup, and a failed one is held nowhere, so + // letting it through would spawn the host's CLI on every refresh for as long as the pause + // lasts and re-extend it each time. + const listed = yield* Effect.flip(service.list({ state: "open", involvement: "all" })); + assert.strictEqual(listed._tag, "PullRequestOperationError"); + assert.strictEqual(viewerLookups, 1); + + // The press is bounded by what the reader does, and its rows are keyed by who they are, so + // it is asked rather than refused. + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/b.ts", viewed: true }], + }); + assert.strictEqual(viewerLookups, 2); + }), +); + it.effect("refuses the marks when the host could not be asked who is reading", () => Effect.gen(function* () { let answering = true; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a767d97e1481..6a8a14cd55b9 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -443,6 +443,7 @@ function withRateLimitBackoff( api: PullRequestProviderApi, host: string, limits: SourceControlRateLimit.SourceControlRateLimit["Service"], + options?: { readonly viewerAllowsPause: boolean }, ): PullRequestProviderApi { const key = { provider: api.kind, host }; const protect = ( @@ -493,12 +494,13 @@ function withRateLimitBackoff( const wrapped = { kind: api.kind, capabilities: api.capabilities, - // Let through a pause, whoever asked. This lookup stands in front of everything else here, - // so refusing it turns a paused host into one that reads as signed out, and refusing it for a - // press the reader made turns that press into a failure. Its answer is then held for the ten - // minutes that signing in moves on, so a paused host is asked at most once for it either way, - // which is not the burst a pause exists to stop. - getViewer: interactive("getViewer", api.getViewer), + // Refused during a pause like any other read, except for the caller that asks for the + // bypass: a lookup that failed is not held, so letting every background read through would + // spawn this host's CLI on each of them and re-extend the pause it was already in. + getViewer: + options?.viewerAllowsPause === true + ? interactive("getViewer", api.getViewer) + : wrap("getViewer", api.getViewer), listChangeRequests: wrap("listChangeRequests", api.listChangeRequests), ...(api.listChangeRequestsAcross === undefined ? {} @@ -850,7 +852,10 @@ export const make = Effect.gen(function* () { if (registered === null) { return Effect.die(new Error(`Missing pull request provider: ${kind}`)); } - const api = withRateLimitBackoff(registered, host, rateLimits); + // Let through a pause: a press the reader is waiting on has to be answered, and the + // callers that are not that press are held back at the gate below instead, before they + // reach this lookup at all. + const api = withRateLimitBackoff(registered, host, rateLimits, { viewerAllowsPause: true }); return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe( Effect.map((viewer) => ({ host, @@ -883,6 +888,7 @@ export const make = Effect.gen(function* () { const resolveViewers = ( projects: ReadonlyArray, viewerRoots: WorkspaceProjects["viewerRoots"], + options?: { readonly allowPaused: boolean }, ) => Effect.forEach( [...new Set(projects.map(({ host }) => host))], @@ -902,7 +908,29 @@ export const make = Effect.gen(function* () { // roots are the same lookup, and putting them on separate flights would spawn two of // this host's CLIs on a cold page load, which is the coalescing this exists for. const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]); - return Cache.get(viewerFlights, key); + if (options?.allowPaused === true) return Cache.get(viewerFlights, key); + // The pause is checked here rather than inside the lookup, so that it holds back the + // callers nobody is waiting on without splitting the flight they share with a press. + // A failed lookup is held nowhere, so letting a background read through would spawn + // this host's CLI on every refresh for as long as the pause lasted, and re-extend it. + return rateLimits.check({ provider: api.kind, host }).pipe( + Effect.flatMap(() => Cache.get(viewerFlights, key)), + Effect.catch((error) => + Effect.succeed({ + host, + kind: api.kind, + viewer: null, + error: new PullRequestProviderError({ + provider: api.kind, + operation: "getViewer", + reason: "rate-limited", + detail: error.detail, + retryAt: error.retryAt, + cause: error, + }), + }), + ), + ); }), { concurrency: REPOSITORY_CONCURRENCY }, ); @@ -1585,13 +1613,14 @@ export const make = Effect.gen(function* () { * would otherwise hide every tick this reader has made and file the next press under rows that * are orphaned once it recovers. The reader is waiting on every one of these paths, a press or * the boxes on a diff they just opened, so the lookup is let through a host's backoff rather - * than failing with it and turning a pause into a refusal. + * than failing with it and turning a pause into a refusal. Scoped to here: the bypass is + * bounded by what the reader does, while a background read would repeat it on every refresh. */ const requiredViewerOf = ( project: SupportedProject, operation: string, ): Effect.Effect => - resolveViewers([project], new Map()).pipe( + resolveViewers([project], new Map(), { allowPaused: true }).pipe( Effect.flatMap(([resolved]) => { const error = resolved?.error ?? null; return error === null From ecaab33cf389c7a96e31b8bac210fffc083773c1 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 12 Sep 2026 14:56:20 -0400 Subject: [PATCH 58/78] docs(user): say where viewed marks can be made The marks read as following a reader everywhere, and the mobile app has no diff to tick them on. Signed-off-by: Yordis Prieto --- docs/user/source-control.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 73718e403976..0f00ad77707a 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -93,6 +93,9 @@ in either direction. GitLab, Bitbucket, and Azure DevOps expose no record T3 Cod server you are connected to keeps them instead: they follow you across the apps connected to that server, but the host's own site will not show them, and the count reads **viewed in T3 Code**. +The **Code** tab is a web and desktop surface. The mobile app reports a pull request's status but +does not show its diff, so marks are made and read on web and desktop. + ## Troubleshooting - **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket, From 5a5b7bdab0a3ba6f1aa8f882acd208554fdc6b83 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 12 Sep 2026 14:57:29 -0400 Subject: [PATCH 59/78] perf(server): keep the Azure summary read to one process A linked thread polls the summary for every pull request each minute, and Azure had no summary read of its own, so the detail read behind it started paying for iterations and their changes to fill in a file count nothing on that path shows. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestProvider.test.ts | 60 +++++++++++++++++++ .../AzureDevOpsPullRequestProvider.ts | 22 +++++++ 2 files changed, 82 insertions(+) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts index cbe53f2cb5ee..1b96984cf76b 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts @@ -134,6 +134,66 @@ function patchedPaths(patch: string): ReadonlyArray { ); } +describe("getChangeRequestSummary", () => { + it.effect("costs the one pull request read, not the iterations changedFiles needs", () => + Effect.gen(function* () { + let pullRequestReads = 0; + + const provider = yield* make.pipe( + Effect.provide( + // listIterations and listIterationChanges are left unimplemented here, so a summary + // that reached for either would die with UnimplementedError instead of this passing. + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => { + pullRequestReads += 1; + return Effect.succeed(PULL_REQUEST); + }, + }), + ), + ); + + 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: "dev.azure.com", + number: 7, + }); + + expect(pullRequestReads).toBe(1); + expect(summary.title).toBe(PULL_REQUEST.title); + expect(summary.changedFiles).toBeUndefined(); + }), + ); +}); + +describe("getChangeRequest", () => { + it.effect("still reports the file count the detail panel needs", () => + Effect.gen(function* () { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => Effect.succeed(PULL_REQUEST), + listIterations: () => Effect.succeed([ITERATION]), + listIterationChanges: () => + Effect.succeed({ changes: [change("a.ts"), change("b.ts")], truncated: false }), + }), + ), + ); + + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "dev.azure.com", + number: 7, + }); + + expect(detail.changedFiles).toBe(2); + }), + ); +}); + describe("getDiff reads", () => { it.effect("holds every reader together to one request's worth of processes", () => Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index e2dc7efeb125..09e6a750ecee 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -21,6 +21,7 @@ import { type ProviderChangeRequest, type ProviderChangeRequestActivity, type ProviderChangeRequestDetail, + type ProviderChangeRequestSummary, type ProviderDiffSlice, type PullRequestProviderApi, } from "./PullRequestProvider.ts"; @@ -327,6 +328,27 @@ export const make = Effect.gen(function* () { })), ), + // The polled path a linked thread's row stays live on: one `az` read, no iterations or + // changes behind it, since the file count that would cost is not shown here. + getChangeRequestSummary: (input) => + cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( + Effect.mapError(fail("getChangeRequestSummary")), + Effect.map((pullRequest): ProviderChangeRequestSummary => ({ + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + author: pullRequest.author, + headBranch: pullRequest.headBranch, + baseBranch: pullRequest.baseBranch, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + mergeability: pullRequest.mergeability, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, + updatedAt: pullRequest.updatedAt, + })), + ), + getChangeRequest: (input) => Effect.gen(function* () { const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }); From e259c2ff1deb6403981452963aca853bd17e8986 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 12 Sep 2026 14:58:58 -0400 Subject: [PATCH 60/78] refactor(server): cut the review prose out of the new server comments Measurements and rejected alternatives belong in the pull request, not beside every branch. Signed-off-by: Yordis Prieto --- .../server/src/pullRequest/azureDevOpsDiff.ts | 142 ++++++------------ .../pullRequest/azureDevOpsPullRequestJson.ts | 14 +- .../src/pullRequest/bitbucketDiffRevisions.ts | 36 ++--- .../src/pullRequest/gitHubPullRequestJson.ts | 22 +-- .../src/pullRequest/gitLabMergeRequestJson.ts | 28 ++-- .../src/pullRequest/pullRequestViewedFiles.ts | 111 ++++++-------- 6 files changed, 120 insertions(+), 233 deletions(-) diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts index f44c6cd67084..42b1fb953e0a 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -5,9 +5,8 @@ import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; /** * How far a diff read got, and which push it was reading. Azure hangs a pull request's changed - * files off an iteration, so the iteration travels with the position: a push landing mid-read - * would otherwise renumber the list under the cursor and hand the reader a file twice or not at - * all. + * files off an iteration, so a push landing mid-read would renumber the list under a cursor that + * did not also pin the iteration. */ export interface AzureDevOpsDiffCursor { readonly iterationId: number; @@ -17,9 +16,8 @@ export interface AzureDevOpsDiffCursor { const CURSOR_SEPARATOR = ":"; /** - * Both halves are plain decimal, because `Number` is wider than what was written: it reads an - * empty or padded half as zero and `0x3` as three, so a cursor this did not write would resume - * from a position nothing ever handed out. + * Restricts each half to plain decimal, so `Number` cannot read a foreign cursor's `0x3` as + * three. */ const CURSOR_COMPONENT = /^\d+$/; @@ -47,10 +45,7 @@ export function parseAzureDevOpsDiffCursor( export interface AzureDevOpsFileTexts { readonly oldContents: string; readonly newContents: string; - /** - * The host's own word on whether this is a file it will not spell out. Azure hands such a file - * over base64-encoded, so its bytes are not in the text to be looked for. - */ + /** Azure's own flag for a file it hands back base64-encoded instead of as text. */ readonly binary: boolean; } @@ -59,23 +54,20 @@ export interface AzureDevOpsFilePatch { /** The file changed but its hunks are not in the section, so the patch has a hole in it. */ readonly truncated: boolean; /** - * The diff was given up on partway rather than declined on sight, so this file spent the whole - * of what one file is allowed and produced a header for it. The caller reading a run of files - * is meant to stop here rather than pay that again for each of the ones behind it. + * The diff was given up on partway, having spent the whole edit budget, so a caller reading a + * run of files stops here rather than paying that again for each one behind it. */ readonly abandoned: boolean; /** - * Lines the diff added or removed, which is the edit distance it had to search out and so what - * the file cost the thread it ran on. The caller reading a run of files spends a budget of these - * rather than of bytes: a file of short lines is cheap on the wire and dear to diff. + * Lines added or removed, which is the edit distance the diff had to search out, and what a + * slice's budget is spent in: a file of short lines is cheap on the wire and dear to diff. */ readonly edits: number; } /** * Beyond this a file is shown as changed without its hunks. Azure hands back whole files rather - * than a patch, so a generated bundle or a checked-in dump is paid for twice over before anything - * can be diffed, and nobody reads the result either way. + * than a patch, so an oversize file is paid for twice over before anything can be diffed. */ const MAX_FILE_BYTES = 512 * 1024; @@ -84,50 +76,28 @@ const PATCH_CONTEXT_LINES = 3; /** * How far apart one file's two sides may be before it is listed without its hunks. The line diff - * searches for the edit distance and costs about the square of it, so a pair of files under the - * size ceiling that share almost nothing would otherwise hold the whole server, and every websocket - * client with it, while it works out a patch of tens of thousands of lines nobody reads. Bounded in - * edits rather than in milliseconds so a change slices the same way on every machine. - * - * Measured over nine runs on a pair at the size ceiling that shares no line at all: 359ms at - * best, 438ms typical, 1223ms at worst. The dearest input this ceiling still admits, at 1998 - * edits, was seen once at 2251ms on a loaded machine, and that is the longest one file can hold - * the thread for. + * costs about the square of the edit distance, so an unbounded pair sharing almost nothing could + * hold the whole server. Bounded in edits rather than milliseconds so a change slices the same + * way on every machine. */ export const MAX_FILE_DIFF_EDITS = 2_000; /** - * A backstop for a machine slower than any the edit ceiling was measured on, and the reason the - * ceiling rather than this is what decides a patch's shape: a timeout that fires decides that - * shape by how fast the machine is, so the same change would slice one way here and another on a - * busier host, and a file the ceiling admits would lose its hunks on the slower of the two. - * - * Headroom over the ceiling is about twice its typical cost rather than the several times it - * would take to put this out of reach, and the dearest input the ceiling admits has been seen - * past this value under load. It holds in practice: thirty runs of that input lost no hunks here, - * against nineteen of thirty at 500ms. + * A backstop for a machine slower than the edit ceiling was tuned for. The ceiling rather than + * this is what decides a patch's shape: a timeout that fires would slice the same change one way + * here and another on a busier host. */ const MAX_FILE_DIFF_MILLIS = 2_000; -/** - * How much diff work one slice does before the rest is left for the next one, which bounds what a - * single request can cost the thread at this and one more file's worth. - */ +/** How much diff work one slice does before the rest is left for the next request. */ export const MAX_DIFF_SLICE_EDITS = 6_000; -/** - * How much patch one slice carries before the rest is left for the next one. Every file costs a - * request per side, so the read stops on what it has produced rather than on a file count: a - * hundred one-line changes are cheaper to finish than three long ones. - */ +/** How much patch one slice carries before the rest is left for the next request. */ export const MAX_DIFF_SLICE_BYTES = 256 * 1024; /** - * How many files one slice carries however little each one weighs. A binary, oversize, purely - * renamed or unreadable entry is a header and nothing else, a couple of hundred bytes with no - * edits at all, so neither budget above stops a run of them until well over a thousand have piled - * up and the request has spent two reads on each. A change of vendored or generated assets is - * exactly that shape, and a listing may hold ten thousand entries of it. + * How many files one slice carries however little each one weighs. A binary, oversize, or + * unreadable entry is just a header, so neither budget above stops a run of thousands of them. */ export const MAX_DIFF_SLICE_FILES = 300; @@ -149,8 +119,7 @@ function isBinary(contents: string): boolean { /** * What a file costs on the wire, which is its bytes rather than its code units: a ceiling counted - * in characters lets a file of three-byte glyphs through at three times the size meant to be let - * through. + * in characters lets a file of three-byte glyphs through at three times the intended size. */ export const byteLength = (contents: string) => Buffer.byteLength(contents, "utf8"); @@ -165,16 +134,11 @@ function hunkRange(start: number, lines: number): string { /** * The `diff --git` preamble a viewer reads a file's identity and fate from. Azure reports no file - * mode, so the ordinary one stands in, exactly as it does for the GitHub files API here. - * - * The names are written the way git writes them, quoted where the name holds anything a header - * cannot carry plainly. Azure names a file in JSON, where a tab or a newline is just another - * character, and a reader of the header takes the name to stop at the first of either: written as - * itself, such a file is read under a shorter name than it has, and the viewed mark a reader puts - * on it is put on a path the host has never heard of. + * mode, so the ordinary one stands in. * - * A side's `a/` or `b/` goes inside the quoting, as git puts it, because the quoting is of the - * whole token the reader takes off the line. A rename states its names with no side to them. + * Names are quoted the way git quotes them: a header reader stops a bare name at its first tab or + * newline, so a path holding either must be quoted or it gets truncated and the viewed mark lands + * on the wrong path. The `a/`/`b/` prefix goes inside the quoting, as git does it. */ function patchHeader(change: AzureDevOpsChangeEntry): string { const oldSide = quoteGitPatchPath(`a/${change.oldPath}`); @@ -196,9 +160,8 @@ function patchHeader(change: AzureDevOpsChangeEntry): string { } /** - * A file written out as wholly replaced: every old line gone, every new line arrived, in one hunk. - * Costs no search at all, around 45ns a line, which is what makes it the whole patch for a file - * that has only one side. + * A file written out as wholly replaced: every old line gone, every new line arrived, in one + * hunk, which needs no edit-distance search at all. */ function replacementSection(header: string, texts: AzureDevOpsFileTexts): string { const oldLines = contentLines(texts.oldContents); @@ -217,9 +180,8 @@ function replacementSection(header: string, texts: AzureDevOpsFileTexts): string } /** - * One file's section of a unified patch, built here because Azure has no route that carries one: - * its diff routes name the files that changed and their blob ids, and the contents are a separate - * read per side. + * One file's section of a unified patch, built here because Azure has no route that returns one: + * its diff routes name the files that changed, and the contents are a separate read per side. */ export function azureDevOpsFilePatch(input: { readonly change: AzureDevOpsChangeEntry; @@ -229,7 +191,7 @@ export function azureDevOpsFilePatch(input: { const { oldContents, newContents } = input.texts; if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) { - // Git's own wording for a file it will not spell out, which every diff viewer already reads. + // Git's own wording for a file it will not spell out. const oldSide = quoteGitPatchPath(`a/${input.change.oldPath}`); const newSide = quoteGitPatchPath(`b/${input.change.path}`); const binary = `Binary files ${oldSide} and ${newSide} differ`; @@ -239,23 +201,16 @@ export function azureDevOpsFilePatch(input: { return { section: `${header}\n`, truncated: true, abandoned: false, edits: 0 }; } - // Nothing on one side is a creation or a deletion, where the whole file is the change and there - // is no edit distance to search out: writing both sides is the minimal patch, and it is linear - // rather than quadratic in the file's length. The edit ceiling has nothing to protect against - // here, and applying it would abandon a large new file after doing no work worth saving. + // A creation or deletion has nothing on one side, so writing both sides in full is already the + // minimal patch and needs no diff search. const created = oldContents === "" && newContents !== ""; const deleted = newContents === "" && oldContents !== ""; if (created || deleted) { const contents = created ? newContents : oldContents; const lines = contentLines(contents); - // A marker on every line puts a side that just fits the size ceiling half again over it, and - // what one file weighs is what a slice's budget is spent in. Such a file is listed without its - // hunks, the same as one whose sides were too big to read at all. - // - // Weighed off the side's own bytes plus the one marker a line will carry, which is strictly - // under what the section costs and needs none of it built. Joining and measuring half a - // megabyte of lines to learn an answer already known is 15 to 120ms, and it is spent on - // exactly the files that hold the request longest. + // A `+`/`-` marker on every line can put a side that just fits the size ceiling over it. + // Checked against bytes-plus-marker-count rather than building the section, since that upper + // bound is cheaper and this is exactly the file shape that would cost the most to build. if (byteLength(contents) + lines.length > MAX_FILE_BYTES) { return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; } @@ -279,12 +234,9 @@ export function azureDevOpsFilePatch(input: { timeout: MAX_FILE_DIFF_MILLIS, }, ); - // The bound is reported by giving nothing back. Such a file is listed without its hunks rather - // than dropped from the change, and rather than written out as wholly replaced: the edit ceiling - // is a distance rather than a proportion, so a long file can reach it having changed in one - // corner, and both sides in full would read as a genuine rewrite and bury that corner in a wall - // of red and green. It spent the whole of what one file is allowed to get here, which is what - // `edits` carries, so the caller reading a run of files stops rather than paying that again. + // Hitting the edit ceiling lists the file without hunks rather than falling back to a full + // replacement: the ceiling is a distance, not a proportion, so a long file can reach it having + // changed in one small corner, and both sides in full would bury that corner in a wall of text. if (patch === undefined) { return { section: `${header}\n`, @@ -304,26 +256,18 @@ export function azureDevOpsFilePatch(input: { ...hunk.lines, ].join("\n"); }); - // A pure rename has no hunks to give. It is still listed, because dropping it would take the - // file out of the change altogether. + // A pure rename has no hunks to give but is still listed, since dropping it would take the file + // out of the change altogether. const section = hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`; - // The edit ceiling bounds how far apart the two sides are, not what the hunks around them - // weigh: a pair of very long lines is a handful of edits and carries both sides in full, and - // three lines of context on each side of every hunk pull in more again. So a file well inside - // the ceiling can still come out heavier than either side was, and what one file weighs is what - // a slice's budget is spent in. Bounded here the same way the wholly-replaced path above is, - // and listed without its hunks rather than dropped. + // The edit ceiling bounds edit distance, not the hunks' size: a handful of very long lines plus + // context can still exceed the size ceiling even well inside the edit ceiling. if (byteLength(section) > MAX_FILE_BYTES) { return { section: `${header}\n`, truncated: true, abandoned: false, edits }; } return { section, truncated: false, abandoned: false, edits }; } -/** - * A file listed without its hunks, for when the host would not hand one of its two sides over. - * The change still belongs in the patch: leaving it out would take the file out of the review - * altogether, and the reader would have no sign anything was missing. - */ +/** A file listed without its hunks, for when the host would not hand one of its two sides over. */ export function azureDevOpsUnreadableFilePatch( change: AzureDevOpsChangeEntry, ): AzureDevOpsFilePatch { diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts index 87c222e94edb..2e949e103959 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts @@ -471,12 +471,9 @@ const decodeChangeEntry = Schema.decodeUnknownExit(RawChangeEntrySchema); const decodeItemContent = decodeJsonResult(RawItemContentSchema); /** - * Azure leads a path with a slash, which is its own spelling rather than part of the name. Every - * other host, and every patch, names the same file without it. - * - * Not trimmed, unlike everything else read out of this payload: a leading or trailing space is a - * legal part of a file's name, and a path trimmed here no longer matches the one the patch and the - * viewed mark are keyed by, so the file is filed under a name nothing else uses. + * Azure leads a path with a slash that every other host and every patch omits. Not trimmed like + * the rest of this payload, since a leading/trailing space is a legal part of a file's name and + * the patch and viewed mark are keyed by the untrimmed path. */ function toRepositoryPath(value: string | null | undefined): string | null { if (value === undefined || value === null) return null; @@ -562,10 +559,7 @@ export function decodeIterationChangesJson( /** * Azure answers an absent file with an empty body rather than an error, which reads as empty. - * - * Whether the bytes are text is Azure's to say and not this decoder's to guess: a file it calls - * binary is reported as such however innocent its first bytes look, since Azure hands the body - * over in an encoding of its own choosing rather than verbatim. + * Whether the bytes are text is Azure's own call, not this decoder's to guess from content. */ export function decodeItemContentJson( raw: string, diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts index 663da3bd35bd..135a23dfdff6 100644 --- a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts +++ b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts @@ -26,12 +26,9 @@ function quotedEnd(rest: string): number { } /** - * `a/x` and `b/x` on a `---` or `+++` line; `/dev/null` is the side that has no file. - * - * Git ends these two lines with a tab when the name holds a space, so that a reader can tell where - * the name stops, and other producers of the format put a timestamp past that tab. A name holding a - * tab of its own arrives quoted, with that tab written as an escape, so the first literal tab is - * never part of what the file is called and everything from it on belongs to git. + * `a/x`/`b/x` on a `---`/`+++` line; `/dev/null` marks the side that has no file. Git ends the + * name with a tab when it holds a space, and a name with a tab of its own arrives quoted with + * that tab escaped, so the first literal tab is never part of what the file is called. */ function sidePath(rest: string, prefix: string): string | null { const tab = rest.indexOf("\t"); @@ -47,14 +44,10 @@ function headerSide(token: string, prefix: string): string | null { } /** - * The two names on a `diff --git` line, which git writes with no delimiter between them. - * - * `a/one two b/one two` splits in more than one place, so the split that leaves both sides equal - * wins. A rename is the only entry whose sides differ, and a rename states its names on lines of - * its own. Anything still ambiguous is left unnamed rather than guessed at. - * - * A quoted name ends at its own closing quote, so a header carrying one splits there and needs - * none of that guessing. Git quotes only the side that needs it, so one side can be quoted alone. + * The two names on a `diff --git` line, written with no delimiter between them. `a/one two b/one + * two` can split in more than one place, so the split leaving both sides equal wins; a rename + * (the only case where sides differ) states its names on separate lines instead. A quoted name + * ends at its own closing quote and needs none of that guessing. */ function headerPaths(rest: string): readonly [string | null, string | null] { if (rest.startsWith(QUOTE)) { @@ -87,17 +80,10 @@ function headRevision(rest: string): string | null { } /** - * What the head has of each file in a unified patch, as the blob ids git writes into it. - * - * Bitbucket states a file's version nowhere else: its diffstat entries carry a commit and a path - * and no blob id, and no endpoint answers what a file is now. Git's own `index ..` - * line is in the patch the diff already reads, so the versions cost no call of their own. - * - * Keyed the way the client names files: the head's name for it, except for a deletion, where the - * head has no name and the one it had is what is on screen. An entry the patch gives no `index` - * line for, one Bitbucket excluded by pattern most often, is left out. `getFileRevisions` in the - * provider turns that into the empty revision where it holds the whole patch and keeps it out - * where the patch was cut, on the tick and the read back alike, so the mark holds either way. + * What the head has of each file, as the blob ids from a unified patch's `index ..` + * line: Bitbucket exposes no blob id for a file anywhere else. Keyed by the head's name, except + * for a deletion where only the old name exists. An entry with no `index` line (most often one + * Bitbucket excluded by pattern) is left out. */ export function parseDiffFileRevisions(patch: string): ReadonlyMap { const revisions = new Map(); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 07a961b04dc3..e04a63bf3a47 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -2519,12 +2519,9 @@ export function decodePullRequestFilesJson( } /** - * Which files of a pull request the signed-in account has cleared. - * - * GraphQL only, since the REST files endpoint the patch is read from carries no viewed state at - * all, so this is a second read rather than a wider version of the first. One page of a hundred - * files costs a single point of the hourly budget, which is why it can ride the diff's own - * refresh without being noticed. + * Which files of a pull request the signed-in account has cleared. GraphQL only, since the REST + * files endpoint the patch is read from carries no viewed state, so this is a second read rather + * than a wider version of the first. */ export const PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $name) { @@ -2610,15 +2607,12 @@ export function decodePullRequestFilesViewedJson( /** * One document that clears and restores as many files as the reader ticked, rather than one - * request each. - * - * GitHub has no bulk form of either mutation, and `markFileAsViewed` and `unmarkFileAsViewed` - * take a single path, so the batching is done with aliases. Top-level mutation fields run in the order - * they are written, so the last word about a path is the one that sticks, and the whole burst - * costs one HTTP round trip and one subprocess instead of one of each per press. + * request each. GitHub has no bulk form of `markFileAsViewed`/`unmarkFileAsViewed`, which each + * take a single path, so the batching is done with aliases; top-level mutation fields run in + * write order, so the last word about a path is the one that sticks. * - * Paths travel as variables rather than inside the document: they are the host's own strings, but - * a path is data and a document is not, and building one out of the other is how injection starts. + * Paths travel as variables rather than interpolated into the document, since a path is data + * and a document is not. */ export function buildSetFilesViewedGraphQlMutation( files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>, diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts index 4c35d4539ca6..1e49e1a9581f 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts @@ -949,12 +949,9 @@ export function decodeOwnAwardIdJson( } /** - * What the given paths are at one revision, as blob ids. - * - * Asked for by path rather than by walking the tree: the caller already knows which files it - * cares about, and GitLab charges this query by how many paths it is given. A path the revision - * does not have comes back missing rather than as an error, which is the answer for a file the - * merge request deletes. + * What the given paths are at one revision, as blob ids. Asked for by path rather than by walking + * the tree, since GitLab charges this query by how many paths it is given. A path the revision + * does not have comes back missing rather than as an error, which is the answer for a deleted file. */ export const REPOSITORY_BLOBS_GRAPHQL_QUERY = `query($fullPath: ID!, $ref: String!, $paths: [String!]!) { project(fullPath: $fullPath) { @@ -1002,15 +999,11 @@ const RawRepositoryBlobsSchema = Schema.Struct({ const decodeRepositoryBlobs = decodeJsonResult(RawRepositoryBlobsSchema); /** - * Blob ids by path, or null where GitLab did not answer the query at all. - * - * A project the token cannot see comes back as `project: null`, and a repository can come back - * without a blobs connection, neither of which says anything about the paths that were asked for. - * That is worth telling apart from a connection that answered: read as "the revision has none of - * these files", an unanswered query reports every file a reader has cleared as changed. - * - * Within an answer, a node missing either half is left out, because half of one names no version, - * and the caller reads an absent path as one the revision does not carry. + * Blob ids by path, or null where GitLab did not answer the query at all (a project the token + * cannot see, or a repository with no blobs connection). That case must be told apart from an + * empty answer: read as "the revision has none of these files", it would report every cleared + * file as changed again. A node missing either half is left out, since the caller treats an + * absent path as one the revision does not carry. */ export function decodeRepositoryBlobsJson( raw: string, @@ -1024,9 +1017,8 @@ export function decodeRepositoryBlobsJson( const blobs = new Map(); for (const node of nodes) { // Not trimmed, unlike everything else read out of this payload: a leading or trailing space - // is a legal part of a file's name, so a path trimmed here is filed under a key neither the - // asked-for path nor the viewed mark is spelled with, and the caller reads the file it was - // asked about as one this revision does not carry. + // is a legal part of a file's name, and trimming it would key this map under a name the + // caller's asked-for path never matches. const path = node?.path; const oid = trimmed(node?.oid); if (path === undefined || path === null || path.length === 0 || oid === null) continue; diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index ad4348f48cd0..06daa339bd3b 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -21,20 +21,17 @@ import type { PullRequestError, SupportedProject } from "./PullRequestService.ts /** * How long the head's version of a file is believed, and how long a held answer stands while the - * next one is fetched. The marks themselves are this environment's own rows and cost nothing to - * read; this is the host call behind the **Changed** badge alone, so a held answer costs a badge - * that is a minute behind rather than a stale tick. + * next one is fetched. This is the host call behind the **Changed** badge alone, so a held answer + * costs a badge that is a minute behind rather than a stale tick. */ const FILE_REVISIONS_CACHE_TTL = Duration.seconds(60); const FILE_REVISIONS_STALE_WINDOW = Duration.minutes(10); export const FILE_REVISIONS_CACHE_CAPACITY = 64; /** - * How many paths one scope's entry carries. The count above bounds how many scopes are held, not - * what any one of them holds: a reader ticking one file after another renews the same scope on - * every press and adds a path to it each time, so a long review of a wide change request grows a - * single entry without limit. Well over what a scope can report marks for, so a trim here only - * ever reaches paths carried from earlier presses. + * How many paths one scope's entry carries. Bounds a single scope, not how many scopes are held: + * a reader ticking one file after another renews the same scope and grows it without limit + * otherwise. */ export const MAX_FILE_REVISION_PATHS = 1_000; @@ -50,10 +47,9 @@ interface FileRevisionsDependencies { const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { const { runFork, refEpoch, fileRevisionsEpoch, toPullRequestError } = dependencies; /** - * What the head has of the files a reader has marked, held between reads. A host says the empty - * revision for a file the change request deletes, and leaves out a path it could not look at, so - * the entry remembers what it has been asked as well as what it heard: a path asked for and - * missing from an answer keeps whatever version was last given for it. + * What the head has of the files a reader has marked, held between reads. The entry tracks what + * has been asked as well as what was heard, since a path missing from an answer keeps whatever + * version was last given for it rather than being cleared. */ interface HeldFileRevisions { readonly at: number; @@ -64,10 +60,9 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { const refreshingFileRevisions = new Set(); /** - * Carries the reference's epoch like the read it serves, so whatever moved the head strands - * what was held against the old one, including an answer still in flight, which stores under - * the key it began with. Normalised, because a reference arrives spelled however the client - * spelled it while the project carries the remote's own spelling. + * Carries the reference's epoch, so whatever moved the head strands what was held (or in + * flight) against the old one. Normalised, since a reference arrives spelled however the + * client spelled it while the project carries the remote's own spelling. */ const fileRevisionsKey = (ref: PullRequestRef) => [ @@ -99,9 +94,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { asked.delete(path); asked.add(path); const revision = answer.get(path); - // Left out of the answer is the host not saying, not the head having nothing: the - // version it last gave stands, since deleting it would turn a file reported as changed - // back into a cleared one. + // A path left out of the answer keeps its last known version rather than being cleared. if (revision !== undefined) { revisions.delete(path); revisions.set(path, revision); @@ -117,9 +110,8 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { const oldest = heldFileRevisions.keys().next().value; if (oldest !== undefined) heldFileRevisions.delete(oldest); } - // The entry is only as fresh as the oldest revision in it: stamping it with now would let - // a reader ticking one new file after another carry the first file's revision past the point - // it would have been read again, since every press renews the scope while asking one path. + // The entry is only as fresh as its oldest revision: stamping it with `now` on a partial + // answer would let an old revision ride past the point it should have been re-read. const stamped = [...revisions.keys()].every((path) => answer.has(path)) ? at : (carried?.at ?? at); @@ -140,14 +132,11 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { }; /** - * What the head has of these files, or null where the host cannot say. Null is not an error: - * without it the marks simply stop reporting staleness, which is worse than the host's own - * record but better than refusing to remember anything. - * - * `held` answers from a value past its lifetime and fetches the next one off the critical path, - * because a badge a moment behind beats a page of ticks that will not paint until a host answers. - * `fresh` is for the press itself, which stamps what it stores and would otherwise write a - * revision the head had already moved off. + * What the head has of these files, or null where the host cannot say (not an error: the marks + * just stop reporting staleness). `held` answers from a stale value and refetches off the + * critical path, since a badge a moment behind beats a page that won't paint until the host + * answers; `fresh` is for the press itself, which must not store a revision the head already + * moved off. */ const fileRevisionsOf = ( project: SupportedProject, @@ -208,17 +197,17 @@ export interface Dependencies extends FileRevisionsDependencies { ) => Effect.Effect; } -// A plain factory rather than a `Context.Service`, against the preference in -// `.repos/effect-smol/LLMS.md`: the held revisions, the refresh set and the write gates are only -// correct at one instance per service, and a layer provided at two points in the graph would give -// two of each behind one epoch counter, which reads as a badge that is quietly wrong. +// A plain factory rather than a `Context.Service` (against the preference in +// `.repos/effect-smol/LLMS.md`): the held revisions, refresh set, and write gates are only correct +// at one instance per service, and a layer provided at two points would give two of each behind +// one epoch counter. export const make = (dependencies: Dependencies) => { const { filesViewedStore, requireProject, requiredViewerOf, toPullRequestError } = dependencies; const { fileRevisionsOf } = makeFileRevisions(dependencies); /** - * Which change request's marks, and whose. Provider and host lead the table's key because the - * same repository exists on more than one install, and the reader is part of it for the reason a - * host's own record is per-account. A host that names no reader is one reader, not none. + * Which change request's marks, and whose. Provider and host lead the key because the same + * repository can exist on more than one install; the reader is part of it because a host's own + * record is per-account. A host that names no reader is one reader, not none. */ const filesViewedScope = (project: SupportedProject, number: number, viewer: string | null) => ({ provider: project.api.kind, @@ -236,12 +225,10 @@ export const make = (dependencies: Dependencies) => { }); /** - * The marks this environment keeps for a host that keeps none of its own. - * - * A file the head still has at the revision it was cleared at is cleared; one the head has - * moved on from is reported as changed, which is what GitHub says of a file pushed to since it - * was ticked. Revisions are asked for the marked paths alone, so a reader who has marked - * nothing costs no host call at all. + * The marks this environment keeps for a host that keeps none of its own. A file the head + * still has at the revision it was cleared at is cleared; one the head has moved on from is + * reported as changed. Revisions are asked for the marked paths alone, so a reader who has + * marked nothing costs no host call. */ const environmentFilesViewed = ( project: SupportedProject, @@ -254,11 +241,8 @@ export const make = (dependencies: Dependencies) => { .pipe(Effect.mapError(toFilesViewedStoreError("filesViewed"))); const marks = held.files; if (marks.length === 0) return { files: [], truncated: held.truncated }; - // A host that will not say what its head has of a file costs the marks their staleness, - // which is what `fileRevisionsOf` answers null for, rather than costing the reader every - // tick they have made. Who the reader is, above, cannot give way like that: these rows are - // keyed by it, so a lookup that failed is reported, and the client says the marks could not - // be read rather than drawing a reader with marks as one with none. + // A host that won't say what its head has costs the marks their staleness (`fileRevisionsOf` + // returns null), rather than costing the reader every tick they've made. const revisions = yield* fileRevisionsOf( project, ref, @@ -274,12 +258,11 @@ export const make = (dependencies: Dependencies) => { ); return { files: marks.map((mark) => { - // A path the host had no answer for is one it could not look at, so the mark holds; a - // file the change request deletes is answered as the empty revision, which is what its - // mark was stamped with, so it is cleared once and stays cleared. A mark stamped with - // no baseline holds for the same reason, until the reader presses it again. + // A mark stamped with no baseline holds until the reader presses it again. if (mark.revision === null) return { path: mark.path, state: "viewed" as const }; const revision = revisions?.get(mark.path); + // A deleted file is answered as the empty revision, matching its stamp, so it stays + // cleared; a path the host had no answer for (`undefined`) also holds as cleared. return { path: mark.path, state: @@ -288,18 +271,15 @@ export const make = (dependencies: Dependencies) => { : ("dismissed" as const), }; }), - // The store carries a bounded number of marks per scope, so a reader who has ticked more - // than that is short of some of them and told so, the same as a host-kept read that ran - // out of pages. + // The store caps marks per scope; a reader over that cap is told so, like a paginated read. truncated: held.truncated, }; }); /** - * One environment-backed write at a time per change request. A tick asks the host what it has - * of the file before it stores anything and an untick asks nothing at all, so two presses in - * quick succession would otherwise finish in the other order and leave the tick's row standing - * over the untick that came after it. + * One environment-backed write at a time per change request. A tick's host round trip is + * slower than an untick's, so unordered presses could finish out of order and leave a stale + * tick standing over a later untick. */ const filesViewedGates = new Map< string, @@ -347,11 +327,8 @@ export const make = (dependencies: Dependencies) => { cleared.length === 0 ? null : yield* fileRevisionsOf(project, input, cleared, "setFilesViewed", "fresh").pipe( - // A host that will not say what its head has, because it is backing off or because - // the CLI is having a bad minute, costs the press its baseline rather than costing - // the reader the press. The mark is stored with none, which holds until it is - // pressed again: the file stops reporting staleness, and nothing is stamped with a - // revision that was never read. + // A host that won't say what its head has costs the press its baseline, not the + // press itself: the mark is stored with none and holds until pressed again. Effect.catch((error) => Effect.logWarning("recording viewed files without what the head has of them", { operation: "setFilesViewed", @@ -363,9 +340,9 @@ export const make = (dependencies: Dependencies) => { yield* filesViewedStore .set({ ...filesViewedScope(project, input.number, viewer), - // A path left out of the answer is the host declining to say, so the mark is stored - // with no baseline rather than with the empty revision, which is an answer and would - // report the file as changed the moment it turns out to have a version after all. + // A path left out of the answer stores with no baseline, not the empty revision, since + // the empty revision is itself an answer and would misreport the file once it turns + // out to have a version after all. files: input.files.map((file) => ({ path: file.path, revision: revisions?.get(file.path) ?? null, From 63ea0789a7ec17e59801e114bf504ced70c61be1 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 12 Sep 2026 15:22:40 -0400 Subject: [PATCH 61/78] fix(web): a tick no longer hides a file pushed to since it landed A press was held until the host agreed with it, so a file pushed to between the write and the read that followed it was answered as changed and the tick outlived the record it stood in for. Nothing could recover it: every later read said the same thing, and the mark itself suppressed the badge that would have shown it. Signed-off-by: Yordis Prieto --- .../pullRequestFilesViewed.logic.test.ts | 44 ++++++++++++++++--- .../pullRequestFilesViewed.logic.ts | 20 ++++++--- .../pullRequest/usePullRequestFilesViewed.ts | 30 +++++++++---- 3 files changed, 74 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts index 88d3f5708c09..72d5a631acb4 100644 --- a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts @@ -13,6 +13,7 @@ import { const NO_OVERLAY: FileViewedOverlay = new Map(); const NOTHING_PENDING: ReadonlySet = new Set(); +const NOTHING_ANSWERED: ReadonlySet = new Set(); const states = toFileViewedStates({ files: [ @@ -57,30 +58,63 @@ describe("countViewedFiles", () => { describe("settleFileViewedOverlay", () => { it("drops a press the host has caught up on", () => { - const settled = settleFileViewedOverlay(new Map([["a.ts", true]]), states, NOTHING_PENDING); + const settled = settleFileViewedOverlay( + new Map([["a.ts", true]]), + states, + NOTHING_PENDING, + NOTHING_ANSWERED, + ); expect(settled.size).toBe(0); }); it("keeps a press the host still disagrees with", () => { const overlay = new Map([["b.ts", true]]); - expect(settleFileViewedOverlay(overlay, states, NOTHING_PENDING)).toBe(overlay); + expect(settleFileViewedOverlay(overlay, states, NOTHING_PENDING, NOTHING_ANSWERED)).toBe( + overlay, + ); }); it("keeps a press the host cannot have heard yet", () => { // An answer already on its way when the file was un-ticked would otherwise put the tick back. const overlay = new Map([["a.ts", false]]); - const settled = settleFileViewedOverlay(overlay, states, new Set(["a.ts"])); + const settled = settleFileViewedOverlay(overlay, states, new Set(["a.ts"]), NOTHING_ANSWERED); expect(settled.get("a.ts")).toBe(false); }); it("settles a file pushed to since it was cleared against un-ticking it", () => { - const settled = settleFileViewedOverlay(new Map([["c.ts", false]]), states, NOTHING_PENDING); + const settled = settleFileViewedOverlay( + new Map([["c.ts", false]]), + states, + NOTHING_PENDING, + NOTHING_ANSWERED, + ); expect(settled.size).toBe(0); }); + it("drops a tick once a read has answered for it, against what the reader pressed", () => { + // The tick landed, the file was pushed to before the read that followed it came back, and the + // host answers `dismissed`. Holding the tick would hide that push for as long as the tab + // stayed open, and no refresh would recover it: every later answer says `dismissed` too. + const settled = settleFileViewedOverlay( + new Map([["c.ts", true]]), + states, + NOTHING_PENDING, + new Set(["c.ts"]), + ); + expect(settled.size).toBe(0); + expect(isFileViewed("c.ts", states, settled)).toBe(false); + expect(isStaleViewedState(states?.get("c.ts"))).toBe(true); + }); + + it("holds a press made since the read that would otherwise answer for it", () => { + const overlay = new Map([["c.ts", true]]); + const settled = settleFileViewedOverlay(overlay, states, new Set(["c.ts"]), new Set(["c.ts"])); + expect(settled.get("c.ts")).toBe(true); + }); + it("holds everything until the host has answered at all", () => { const overlay = new Map([["a.ts", true]]); - expect(settleFileViewedOverlay(overlay, null, NOTHING_PENDING)).toBe(overlay); + expect(settleFileViewedOverlay(overlay, null, NOTHING_PENDING, NOTHING_ANSWERED)).toBe(overlay); }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts index 55a779bee993..fb3f021ce052 100644 --- a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts @@ -53,23 +53,29 @@ export function countViewedFiles( /** * The overlay with everything the host has caught up on removed. * - * A press is held locally until the host's own answer agrees with it, rather than cleared when - * the request succeeds: the read that follows a write is a separate round trip, and dropping the - * press in between would flash the checkbox back for as long as that took. + * A press is held locally until a read that could have seen it comes back, rather than cleared + * when the request succeeds: the read that follows a write is a separate round trip, and dropping + * the press in between would flash the checkbox back for as long as that took. * - * `unsettled` are the paths whose press the host cannot have heard yet, which an answer that was + * `pending` are the paths whose press the host cannot have heard yet, which an answer that was * already on its way when they were pressed must not be allowed to overrule. + * + * `answered` are the paths a read has landed for since their write was acknowledged. Those go on + * that read alone, including where it contradicts the press: the host is the record of what has + * been looked at, and a tick held over a `dismissed` would hide a file pushed to since for as + * long as the tab stayed open, with no refresh able to recover it. */ export function settleFileViewedOverlay( overlay: FileViewedOverlay, states: FileViewedStates | null, - unsettled: ReadonlySet, + pending: ReadonlySet, + answered: ReadonlySet, ): FileViewedOverlay { if (states === null || overlay.size === 0) return overlay; const next = new Map(overlay); for (const [path, pressed] of overlay) { - if (unsettled.has(path)) continue; - if (isViewedState(states.get(path)) === pressed) next.delete(path); + if (pending.has(path)) continue; + if (answered.has(path) || isViewedState(states.get(path)) === pressed) next.delete(path); } return next.size === overlay.size ? overlay : next; } diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index 1cf3dc7bb93a..8e00d353d2e9 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -16,6 +16,7 @@ import { toFileViewedBatch, toFileViewedStates, type FileViewedOverlay, + type FileViewedStates, } from "./pullRequestFilesViewed.logic"; /** @@ -53,7 +54,8 @@ export interface PullRequestFilesViewedView { * * The marks live on the server rather than in this tab, so a review carried on from another * machine picks up where it was left. Presses show immediately and are held over the server's - * answer until it agrees with them, so the checkbox never waits on a round trip. + * answer until a read that could have seen them comes back, so the checkbox never waits on a + * round trip and never outlasts the record it stands in for. */ export function usePullRequestFilesViewed(options: { readonly environmentId: EnvironmentId; @@ -93,14 +95,22 @@ export function usePullRequestFilesViewed(options: { const scopeKey = `${environmentId} ${reference.projectId} ${reference.repository} ${reference.number}`; const scope = useRef(scopeKey); + // The host's answer as it stood when a completed write was acknowledged, per path. The first + // answer that differs from it is the first read that could have seen the write, which is what + // retires the press rather than the host happening to agree with it. + const answeredFrom = useRef>(new Map()); + const statesRef = useRef(states); + statesRef.current = states; + useEffect(() => { - setOverlay((current) => - settleFileViewedOverlay( - current, - states, - new Set([...queued.current.keys(), ...sentBy.current.keys()]), - ), - ); + const pending = new Set([...queued.current.keys(), ...sentBy.current.keys()]); + const answered = new Set(); + for (const [path, from] of answeredFrom.current) { + if (pending.has(path) || from === states) continue; + answered.add(path); + answeredFrom.current.delete(path); + } + setOverlay((current) => settleFileViewedOverlay(current, states, pending, answered)); }, [states]); const flush = useCallback(() => { @@ -132,6 +142,9 @@ export function usePullRequestFilesViewed(options: { } return; } + // Answered for from the next read on, whatever it says. A push landing between the write + // and that read comes back as `dismissed`, and the press must not stand over it. + for (const path of mine) answeredFrom.current.set(path, statesRef.current); refresh(); }); }, [environmentId, reference, refresh, setFilesViewed]); @@ -154,6 +167,7 @@ export function usePullRequestFilesViewed(options: { } queued.current = new Map(); sentBy.current = new Map(); + answeredFrom.current = new Map(); setOverlay(NO_OVERLAY); }; }, [scopeKey]); From 9ec8fd41b7aaadde10b6b48cbaf87f4dcb973b5f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 12 Sep 2026 15:23:05 -0400 Subject: [PATCH 62/78] docs(internals): the revision guide agrees with itself on a failed press It claimed a press with no revision is refused, two paragraphs above describing the mark it actually stores. Signed-off-by: Yordis Prieto --- docs/internals/pull-request-file-revisions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/internals/pull-request-file-revisions.md b/docs/internals/pull-request-file-revisions.md index 24566767dcac..3a84b8de665d 100644 --- a/docs/internals/pull-request-file-revisions.md +++ b/docs/internals/pull-request-file-revisions.md @@ -45,9 +45,9 @@ forms are not interchangeable. A null map, from the service's read of the whole scope, costs those marks their staleness. They still report as cleared; they stop noticing pushes. It happens when the provider offers no -`getFileRevisions` at all, and, on the read path only, when the call failed and was logged. A -press takes the stricter line and fails loudly, since a mark stamped with a revision nobody read -is wrong rather than merely less informed. +`getFileRevisions` at all, and, on either path, when the call failed and was logged. A press that +gets no answer does not refuse the reader's tick: it stores the mark with no baseline, which is +the third form below. A path absent from an answered map is the per-path case, and it is the one that must not be read as a deletion. [`HeldFileRevisions`](../../apps/server/src/pullRequest/pullRequestViewedFiles.ts) From e92c9f64783ecad0296502868188f7a1b8738c86 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 12 Sep 2026 15:30:41 -0400 Subject: [PATCH 63/78] test(web): the case where a tick hid a push is guarded through the hook The helper that gives way to the host was covered, but nothing held the hook to telling it a read had landed, which is where the fault sat. Signed-off-by: Yordis Prieto --- .../usePullRequestFilesViewed.test.tsx | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx new file mode 100644 index 000000000000..78314b7ca7d0 --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx @@ -0,0 +1,161 @@ +import { + EnvironmentId, + ProjectId, + type PullRequestFilesViewedResult, + type PullRequestRef, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, StrictMode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { host, setFilesViewed, toastAdd } = vi.hoisted(() => ({ + host: { data: null as unknown, refresh: vi.fn() }, + setFilesViewed: vi.fn(), + toastAdd: vi.fn(), +})); + +vi.mock("~/state/pullRequests", () => ({ + pullRequestEnvironment: { filesViewed: () => null, setFilesViewed: {} }, +})); +vi.mock("~/state/query", () => ({ + useEnvironmentQuery: () => ({ + data: host.data, + error: null, + isPending: false, + isSuccess: true, + refresh: host.refresh, + }), +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => setFilesViewed })); +vi.mock("../ui/toast", () => ({ toastManager: { add: toastAdd } })); + +import { + usePullRequestFilesViewed, + type PullRequestFilesViewedView, +} from "./usePullRequestFilesViewed"; + +const environmentId = EnvironmentId.make("pr-files-viewed-audit"); +const reference: PullRequestRef = { + projectId: ProjectId.make("project-a"), + repository: "acme/web", + number: 42, +}; +const paths = ["a.ts"]; + +/** What the host answers, as a fresh object each time: a read is only a read if it is a new one. */ +function answer(state: "unviewed" | "viewed" | "dismissed"): PullRequestFilesViewedResult { + return { files: [{ path: "a.ts", state }], truncated: false }; +} + +let renderer: ReactTestRenderer | null = null; + +function Probe(_props: { readonly view: PullRequestFilesViewedView }) { + return null; +} + +function Surface() { + const view = usePullRequestFilesViewed({ environmentId, reference, enabled: true, paths }); + return ; +} + +function view(): PullRequestFilesViewedView { + return renderer!.root.findByType(Probe).props.view; +} + +/** The host's next answer landing, which is what a `refresh` ends in. */ +async function reads(state: "unviewed" | "viewed" | "dismissed") { + host.data = answer(state); + await act(async () => + renderer!.update( + + + , + ), + ); +} + +beforeEach(async () => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + host.data = answer("unviewed"); + host.refresh.mockReset(); + toastAdd.mockReset(); + setFilesViewed.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + act(() => { + renderer = create( + + + , + ); + }); +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = null; + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("a mark whose file was pushed to before the read that followed it", () => { + it("gives way to the host and shows the file as changed", async () => { + view().setViewed("a.ts", true); + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(setFilesViewed).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { ...reference, files: [{ path: "a.ts", viewed: true }] }, + }); + expect(host.refresh).toHaveBeenCalled(); + + // The push landed between the write and this read, so the host answers `dismissed` rather + // than the `viewed` the press asked for. + await reads("dismissed"); + + expect(view().isViewed("a.ts")).toBe(false); + expect(view().isStale("a.ts")).toBe(true); + expect(view().viewedCount).toBe(0); + }); + + it("stays given way to on every later read, having nothing left to recover", async () => { + view().setViewed("a.ts", true); + await act(async () => vi.advanceTimersByTimeAsync(500)); + await reads("dismissed"); + + view().refresh(); + await reads("dismissed"); + + expect(view().isViewed("a.ts")).toBe(false); + expect(view().isStale("a.ts")).toBe(true); + }); +}); + +describe("a mark the host has not answered for yet", () => { + it("holds the press while the write is still out", async () => { + let land = (_result: unknown) => {}; + setFilesViewed.mockReturnValueOnce(new Promise((resolve) => (land = resolve))); + + view().setViewed("a.ts", true); + await act(async () => vi.advanceTimersByTimeAsync(500)); + + // An answer already on its way when the box was ticked must not put it back. + await reads("unviewed"); + expect(view().isViewed("a.ts")).toBe(true); + + await act(async () => land(AsyncResult.success(undefined))); + expect(view().isViewed("a.ts")).toBe(true); + }); + + it("holds a press made since the read that would otherwise answer for it", async () => { + view().setViewed("a.ts", true); + await act(async () => vi.advanceTimersByTimeAsync(500)); + + // Pressed again before the post-write read came back. That press is the one on screen, and + // the read that answers for the first one says nothing about it. + view().setViewed("a.ts", true); + await reads("dismissed"); + + expect(view().isViewed("a.ts")).toBe(true); + expect(view().isStale("a.ts")).toBe(false); + }); +}); From 8f68fbfe1200fa73d283aed32b4c00266327e5cf Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 12 Sep 2026 15:58:19 -0400 Subject: [PATCH 64/78] perf(server): stop re-reading a bitbucket pull request's patch on every tick Bitbucket has no per-file version to read, so one file's revision costs the whole change's patch. It was parsing every file in it and keeping only the path asked about, which left the next tick on a file nobody had asked about before paying for the same download again. Signed-off-by: Yordis Prieto --- .../BitbucketPullRequestApi.test.ts | 23 ++++--- .../pullRequest/BitbucketPullRequestApi.ts | 24 ++++--- .../BitbucketPullRequestProvider.ts | 5 +- .../src/pullRequest/PullRequestProvider.ts | 10 +++ .../pullRequest/PullRequestService.test.ts | 62 +++++++++++++++++++ .../src/pullRequest/pullRequestViewedFiles.ts | 20 ++++-- 6 files changed, 119 insertions(+), 25 deletions(-) diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index 6a5af425d05e..49cdaa90f7d2 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -364,7 +364,7 @@ layer("BitbucketPullRequestApi.layer", (it) => { }), ); - it.effect("reads file versions out of the patch, for the paths it was asked about", () => + it.effect("reads every file version the patch states, not only the paths asked about", () => Effect.gen(function* () { mockedRequest.mockReturnValueOnce( Effect.succeed( @@ -397,16 +397,19 @@ layer("BitbucketPullRequestApi.layer", (it) => { paths: ["a.ts", "missing.ts"], }); - // `b.ts` is in the patch and was not asked about, so it does not belong in the answer. + // `b.ts` was not asked about and is reported anyway: parsing the patch for `a.ts` read it + // too, and the caller holding it is what stops the next tick paying for the patch again. // `missing.ts` was asked about and the whole patch was read without finding it, which is // what a file this pull request deletes looks like, so it is answered as the empty version. assert.deepStrictEqual( - [...revisions], + [...revisions.revisions], [ ["a.ts", "2222222"], + ["b.ts", "4444444"], ["missing.ts", ""], ], ); + assert.strictEqual(revisions.complete, true); expect(callAt(0)).toMatchObject({ url: "/repositories/acme/web/pullrequests/71/diff" }); }), ); @@ -429,7 +432,9 @@ layer("BitbucketPullRequestApi.layer", (it) => { paths: ["a.ts", "past-the-cut.ts"], }); - assert.deepStrictEqual([...revisions], [["a.ts", "2222222"]]); + assert.deepStrictEqual([...revisions.revisions], [["a.ts", "2222222"]]); + // `past-the-cut.ts` gets no empty version, and nothing here may be held as the whole story. + assert.strictEqual(revisions.complete, false); }), ); @@ -464,8 +469,12 @@ layer("BitbucketPullRequestApi.layer", (it) => { paths: ["b.ts"], }); - assert.deepStrictEqual([...first], [["a.ts", "2222222"]]); - assert.deepStrictEqual([...second], [["b.ts", "4444444"]]); + const both = [ + ["a.ts", "2222222"], + ["b.ts", "4444444"], + ]; + assert.deepStrictEqual([...first.revisions], both); + assert.deepStrictEqual([...second.revisions], both); assert.strictEqual(mockedRequest.mock.calls.length, 1); }), ); @@ -503,7 +512,7 @@ layer("BitbucketPullRequestApi.layer", (it) => { paths: [], }); - assert.strictEqual(revisions.size, 0); + assert.strictEqual(revisions.revisions.size, 0); assert.strictEqual(mockedRequest.mock.calls.length, 0); }), ); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index 0f36b020e802..ae19c6d078a2 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -200,12 +200,20 @@ export class BitbucketPullRequestApi extends Context.Service< * Read off the pull request's own patch, the only place Bitbucket states a file's version. A * path the patch does not carry is answered as the empty revision, and left out altogether * when the patch was cut short at the byte ceiling and so cannot be spoken for. + * + * Every file the patch carries, not only the paths asked about: reading one file's version + * here means parsing all of them, and the caller holding the rest is what stops the tick + * after this one paying for the same patch again. `complete` is false for a patch cut short, + * which cannot speak for what came after the cut. */ readonly getFileRevisions: (input: { readonly repository: string; readonly number: number; readonly paths: ReadonlyArray; - }) => Effect.Effect, BitbucketPullRequestApiError>; + }) => Effect.Effect< + { readonly revisions: ReadonlyMap; readonly complete: boolean }, + BitbucketPullRequestApiError + >; readonly getMergeability: (input: { readonly repository: string; @@ -679,18 +687,18 @@ export const make = Effect.gen(function* () { getFileRevisions: (input) => input.paths.length === 0 - ? Effect.succeed(new Map()) + ? Effect.succeed({ revisions: new Map(), complete: false }) : Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe( Effect.map((diff) => { + const revisions = new Map(diff.revisions); // A patch cut short at the byte ceiling says nothing about the files past the cut, // so those paths are left out rather than reported as removed. - const asked = new Map(); - for (const path of input.paths) { - const revision = diff.revisions.get(path); - if (revision !== undefined) asked.set(path, revision); - else if (!diff.truncated) asked.set(path, ""); + if (!diff.truncated) { + for (const path of input.paths) { + if (!revisions.has(path)) revisions.set(path, ""); + } } - return asked; + return { revisions, complete: !diff.truncated }; }), ), diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index d493097afc2c..b9fe9d669f4a 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -246,10 +246,7 @@ export const make = Effect.gen(function* () { number: input.number, paths: input.paths, }) - .pipe( - Effect.mapError(fail("getFileRevisions")), - Effect.map((revisions) => ({ revisions })), - ), + .pipe(Effect.mapError(fail("getFileRevisions"))), // Users only: Bitbucket requests a review of an account, and has no group that stands in for // one on a pull request. diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 1e42c0d5fecd..fe6b68f35110 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -282,6 +282,16 @@ export interface ProviderFilesViewed { */ export interface ProviderFileRevisions { readonly revisions: ReadonlyMap; + /** + * Whether these are every file the change request carries rather than only the paths asked + * about. A host with no per-file version reads the whole change to answer for one file, and + * saying so is what keeps the next tick from making it read the whole change again: the caller + * holds what it is told, and a tick names a path nothing has asked about before. + * + * Only for an answer that can speak for the whole change. A read cut short part way through + * says nothing about what came after it, so it reports the paths it was asked about and no more. + */ + readonly complete?: boolean; } export interface ProviderRepositoryRef { diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index d40cdabda2e9..611e73de85d2 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -4684,6 +4684,68 @@ it.effect("keeps viewed files itself for a host that keeps none of its own", () }), ); +it.effect("holds what a whole-change answer carried, so the next tick reads nothing", () => + Effect.gen(function* () { + const asked: Array> = []; + const head = new Map([ + ["src/a.ts", "blob-a"], + ["src/b.ts", "blob-b"], + ]); + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + { + ...environmentViewedProvider(head, []), + // A host with no per-file version reads the whole change to answer for one file, which + // is what Bitbucket's patch is, and says as much. + getFileRevisions: (input) => { + asked.push(input.paths); + return Effect.succeed({ revisions: head, complete: true }); + }, + }, + ], + }); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + // A path nothing has asked about before, which is what every tick after the first names. Its + // version came back with the first answer, so there is nothing left to read it for. + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/b.ts", viewed: true }], + }); + + assert.deepStrictEqual(asked, [["src/a.ts"]]); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + assert.deepStrictEqual( + [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { path: "src/a.ts", state: "viewed" }, + { path: "src/b.ts", state: "viewed" }, + ], + ); + + // Still only as fresh as the read it came from: past that window the press reads again rather + // than stamping a mark with a version the head may have moved off. + yield* TestClock.adjust("2 minutes"); + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/b.ts", viewed: true }], + }); + assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]); + }), +); + it.effect("reads the marks without asking the host what the head has every time", () => Effect.gen(function* () { const asked: Array> = []; diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index 06daa339bd3b..10374b19c4ab 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -16,7 +16,7 @@ import { } from "@t3tools/contracts"; import type * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; -import type { PullRequestProviderError } from "./PullRequestProvider.ts"; +import type { ProviderFileRevisions, PullRequestProviderError } from "./PullRequestProvider.ts"; import type { PullRequestError, SupportedProject } from "./PullRequestService.ts"; /** @@ -73,10 +73,17 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { ref.number, ].join(" "); + /** + * `paths` are what was asked about, and are held as answered for whether the host had a version + * for them or not: that is what stops the same question being asked again. A `complete` answer + * adds every other path it carries, since a host that read the whole change to answer for one + * file has already paid for all of them, and the tick after this one names a file nothing has + * asked about yet. + */ const recordFileRevisions = ( key: string, paths: ReadonlyArray, - answer: ReadonlyMap, + answer: ProviderFileRevisions, ) => Effect.map(Clock.currentTimeMillis, (at) => { const held = heldFileRevisions.get(key); @@ -88,12 +95,13 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { : null; const revisions = new Map(carried?.revisions ?? []); const asked = new Set(carried?.asked ?? []); - for (const path of paths) { + const learned = answer.complete === true ? [...paths, ...answer.revisions.keys()] : paths; + for (const path of learned) { // Reinserted rather than added, so what a full entry drops below is the path nobody has // asked about in the longest rather than one just asked for. asked.delete(path); asked.add(path); - const revision = answer.get(path); + const revision = answer.revisions.get(path); // A path left out of the answer keeps its last known version rather than being cleared. if (revision !== undefined) { revisions.delete(path); @@ -112,7 +120,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { } // The entry is only as fresh as its oldest revision: stamping it with `now` on a partial // answer would let an old revision ride past the point it should have been re-read. - const stamped = [...revisions.keys()].every((path) => answer.has(path)) + const stamped = [...revisions.keys()].every((path) => answer.revisions.has(path)) ? at : (carried?.at ?? at); heldFileRevisions.set(key, { at: stamped, asked, revisions }); @@ -159,7 +167,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { paths, }).pipe( Effect.mapError(toPullRequestError(operation)), - Effect.flatMap((answer) => recordFileRevisions(key, paths, answer.revisions)), + Effect.flatMap((answer) => recordFileRevisions(key, paths, answer)), ); }); return Effect.flatMap(Clock.currentTimeMillis, (now) => { From 69e684d0615c6583cd5a35ffec72589599ffaa37 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 12 Sep 2026 16:02:46 -0400 Subject: [PATCH 65/78] docs(server): thin out the comments around viewed files Several of them argued their way to the code below rather than saying what a reader needs to get it right, and the measurements and rejected alternatives belong in the pull request that made them, not next to the code forever. Signed-off-by: Yordis Prieto --- .../src/persistence/PullRequestFilesViewed.ts | 22 +++---- .../pullRequest/AzureDevOpsPullRequestCli.ts | 30 ++++------ .../AzureDevOpsPullRequestProvider.ts | 40 +++++-------- .../pullRequest/BitbucketPullRequestApi.ts | 34 +++++------ .../src/pullRequest/PullRequestProvider.ts | 35 ++++------- .../src/pullRequest/PullRequestService.ts | 7 +-- .../server/src/pullRequest/azureDevOpsDiff.ts | 8 +-- .../pullRequestFilesViewed.logic.ts | 38 +++++------- .../pullRequest/usePullRequestFilesViewed.ts | 10 ++-- apps/web/src/lib/diffRendering.ts | 11 ++-- .../client-runtime/src/state/pullRequests.ts | 10 +--- packages/contracts/src/pullRequest.ts | 55 ++++++++---------- packages/contracts/src/rpc.ts | 5 -- packages/shared/src/git.ts | 1 - packages/shared/src/gitPatchPath.ts | 58 +++++++------------ 15 files changed, 134 insertions(+), 230 deletions(-) diff --git a/apps/server/src/persistence/PullRequestFilesViewed.ts b/apps/server/src/persistence/PullRequestFilesViewed.ts index af7856de272d..9904e244018a 100644 --- a/apps/server/src/persistence/PullRequestFilesViewed.ts +++ b/apps/server/src/persistence/PullRequestFilesViewed.ts @@ -14,13 +14,10 @@ import { } from "./Errors.ts"; /** - * Which change request, on which host, for which reader. - * - * The host is part of it because a repository path is not unique across installs: the same - * `group/project` exists on gitlab.com and on a self-managed instance, and a mark made against one - * must not turn up on the other. The reader is part of it for the same reason the host's own - * record is per-account: signing in as somebody else must not inherit their ticks. A host that - * will not say who the reader is leaves it empty, which is one reader rather than none. + * Which change request, on which host, for which reader. The host is part of it because the same + * `group/project` exists on gitlab.com and on a self-managed instance, and the reader because + * signing in as somebody else must not inherit their ticks. A host that will not say who the + * reader is leaves it empty, which is one reader rather than none. */ export const PullRequestFilesViewedScope = Schema.Struct({ provider: SourceControlProviderKind, @@ -35,13 +32,10 @@ export type PullRequestFilesViewedScope = typeof PullRequestFilesViewedScope.Typ export const PullRequestFileViewedMark = Schema.Struct({ path: Schema.String, /** - * The host's own name for that version of the file, opaque here. - * - * Empty where the host said it had none to give, which is its own answer rather than a missing - * one: a file with no version at the head is one the change request deletes, and it stays - * deleted. Null where the host could not say at all, which is no baseline rather than an empty - * one: stamping such a mark with the empty revision would report the file as changed the moment - * anything did answer, so a mark with no baseline stays cleared until a press replaces it. + * The host's own name for that version of the file, opaque here. Empty where the host said it + * had none to give, which is an answer rather than a gap: a file with no version at the head is + * one the change request deletes. Null where the host could not say at all, which is no baseline + * rather than an empty one, and such a mark stays cleared until a press replaces it. * * This null is the only one this environment invents; the other two are in * `docs/internals/pull-request-file-revisions.md`. diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 150bfa7bb19c..5f5453a9b3ba 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -133,13 +133,10 @@ const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; */ const ITEM_CONTENT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; /** - * What a review's own history is given. Neither of these routes pages, so each answers with the - * whole of it at once and grows with how long the review ran rather than with how large the change - * is. Threads are the nearer ceiling of the two: Azure opens one per vote and per ref update - * alongside the ones people wrote, and every comment carries a full identity beside its text, so - * the answer is far larger than the handful of fields read back out of it. Cut at the default, - * both arrive as JSON that stops mid-string, and a long review would report its host as answering - * with nonsense. + * What a review's own history is given. Neither route pages, so each answers with the whole of it + * at once and grows with how long the review ran rather than with how large the change is. Cut at + * the default, both arrive as JSON that stops mid-string, and a long review would report its host + * as answering with nonsense. */ const REVIEW_HISTORY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; @@ -148,13 +145,10 @@ const CHANGE_ENTRIES_PER_PAGE = 2000; /** * Where following the pages stops, counted in the entries Azure was asked to skip rather than in - * the files that survived decoding. Every page is an `az` process of its own, and a change this - * long is past what any reader will get through, so the read gives up rather than spending a - * minute of spawns on it. Saying so is the point: the diff reports itself as incomplete instead - * of presenting five pages as the whole change. - * - * Azure's own count is what bounds this, because a page can be entirely folders and other entries - * a review has nothing to show for. Bounding on what was kept would follow such a change forever. + * the files that survived decoding: a page can be entirely folders and other entries a review has + * nothing to show for, and bounding on what was kept would follow such a change forever. Every + * page is an `az` process of its own, so the read gives up and reports itself incomplete rather + * than presenting five pages as the whole change. */ const MAX_CHANGE_ENTRIES = 10_000; @@ -367,10 +361,10 @@ export const make = Effect.gen(function* () { /** * A REST route reached through `az devops invoke`, which addresses it by area, resource and - * route parameters rather than by URL. It is used in place of `az rest` because it signs in the - * way the azure-devops extension does, and `az rest` mints its own token against the tenant `az` - * defaults to. For an organisation in any other tenant that token is rejected and Azure answers - * with a sign-in page, which arrives here as unreadable output rather than as a failure. + * route parameters rather than by URL. Used in place of `az rest` because it signs in the way + * the azure-devops extension does: `az rest` mints its own token against the tenant `az` + * defaults to, and an organisation in any other tenant answers that with a sign-in page, which + * arrives here as unreadable output rather than as a failure. */ const invoke = (input: { readonly cwd: string; diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 09e6a750ecee..b194e5004734 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -182,11 +182,8 @@ export const make = Effect.gen(function* () { * Where a pull request's repository lives, which is the route every other read of it needs and * the one thing only the pull request itself states. A pull request cannot move between * repositories, so it is remembered rather than re-read: the marks alone would otherwise pay for - * a whole pull request read every time they checked whether a file had been pushed to. - * - * Bounded and least recently used, since a long-lived server sees far more pull requests than a - * reader ever has open: first in would let a listing walking cold pull requests evict the one - * being read, and every mark on it would then pay a whole pull request read again. + * a whole pull request read every time they checked whether a file had been pushed to. Least + * recently used, so a listing walking cold pull requests cannot evict the one being read. */ const locations = new Map(); @@ -503,14 +500,12 @@ export const make = Effect.gen(function* () { truncated = truncated || file.truncated; index += 1; // A file whose diff was given up on spent the whole of what one file is allowed and - // has only a header to show for it, so the byte budget alone would let a change full - // of them spend that over and over in one request. A file the diff never ran on at - // all, because it is binary or oversize or only renamed, weighs almost nothing in - // either budget and still costs its two reads, which is what the file count bounds. - // Checked after the file is added - // rather than before it, so every slice carries at least one: a section heavier than - // the whole budget would otherwise never be added, and the read would answer the same - // slice forever without moving the cursor. + // has only a header to show for it, and a file the diff never ran on at all weighs + // almost nothing in either budget and still costs its two reads. So the file count + // bounds the request alongside the bytes. + // Checked after the file is added rather than before it, so every slice carries at + // least one: a section heavier than the whole budget would otherwise never be added, + // and the read would answer the same slice forever without moving the cursor. if ( bytes >= MAX_DIFF_SLICE_BYTES || edits + MAX_FILE_DIFF_EDITS > MAX_DIFF_SLICE_EDITS || @@ -535,12 +530,9 @@ export const make = Effect.gen(function* () { }).pipe(Effect.mapError(fail("getDiff"))), // The patch is built from whole files, so opening the lines around a hunk is the same two - // reads over again rather than a wider request. - // - // Read against the latest iteration, which is the one the patch was taken against unless a - // push landed in between. Nothing in the request says which push the reader is looking at, so - // there is no older iteration to go back to: expansion is stale after a mid-review push on - // every host here, and the diff it belongs to is stale with it. + // reads over again rather than a wider request. Read against the latest iteration: nothing in + // the request says which push the reader is looking at, and expansion is stale after a + // mid-review push on every host here anyway. getDiffFileContents: (input) => Effect.gen(function* () { const scope = yield* diffScope(input); @@ -560,13 +552,11 @@ export const make = Effect.gen(function* () { /** * What the head has of each marked file, which is the blob Azure already names on the change - * it reports. One read covers every path: the latest iteration lists the whole change, so - * asking per file would be the same answer fetched over and over. + * it reports. One read covers every path, since the latest iteration lists the whole change. * - * A path the change does not carry is at the empty revision, which is what a file the pull - * request deletes is at and leaves it cleared once and cleared for good. When the change was - * too long to follow to its end, those paths are left out instead: they were not looked at, - * and reporting them as deleted would clear a file nobody has read. + * A path the change does not carry is at the empty revision, which is where a file the pull + * request deletes sits. When the change was too long to follow to its end, those paths are + * left out instead: reporting them as deleted would clear a file nobody has read. */ getFileRevisions: (input) => Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index ae19c6d078a2..c7d017071fc6 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -140,11 +140,9 @@ const CONVERSATION_PAGES = 10; /** The same ceiling the gh and glab diff reads use. */ const DIFF_MAX_BYTES = 8 * 1024 * 1024; /** - * A reader ticking files off names one new path at a time, and a path the caller has not asked - * about before cannot be answered from what it holds, so without this every tick pays for the - * whole patch again. Deliberately far shorter than the window the caller holds versions for: - * a refresh drops what the caller holds precisely so the next read reaches Bitbucket, and this - * must not be what answers it instead. + * Deliberately far shorter than the window the caller holds versions for: a refresh drops what the + * caller holds precisely so the next read reaches Bitbucket, and this must not be what answers it + * instead. */ const REVISION_PATCH_TTL = Duration.seconds(5); const REVISION_PATCH_CAPACITY = 16; @@ -195,16 +193,14 @@ export class BitbucketPullRequestApi extends Context.Service< }) => Effect.Effect; /** - * What the pull request's head has of each of these paths, as opaque ids. + * What the pull request's head has of each of these paths, as opaque ids, read off the pull + * request's own patch, the only place Bitbucket states a file's version. A path the patch does + * not carry is answered as the empty revision, and left out altogether when the patch was cut + * short at the byte ceiling and so cannot be spoken for. * - * Read off the pull request's own patch, the only place Bitbucket states a file's version. A - * path the patch does not carry is answered as the empty revision, and left out altogether - * when the patch was cut short at the byte ceiling and so cannot be spoken for. - * - * Every file the patch carries, not only the paths asked about: reading one file's version - * here means parsing all of them, and the caller holding the rest is what stops the tick - * after this one paying for the same patch again. `complete` is false for a patch cut short, - * which cannot speak for what came after the cut. + * Answers with every file the patch carries rather than only the paths asked about, since + * reading one file's version here means parsing all of them. `complete` is false for a patch + * cut short, which cannot speak for what came after the cut. */ readonly getFileRevisions: (input: { readonly repository: string; @@ -587,12 +583,10 @@ export const make = Effect.gen(function* () { ); /** - * What the version reads that come one tick at a time actually want out of the pull request's - * whole patch, shared between them. The patch itself is not what is held: at this capacity that - * would be sixteen bodies of up to the byte ceiling each resident, and V8 stores a body with a - * single non-Latin-1 character anywhere in it two bytes to the character. This is the same - * answer some thousands of times smaller, and it saves walking a patch of a hundred thousand - * lines again on every tick. + * What the version reads that come one tick at a time want out of the pull request's whole + * patch, shared between them. The parsed answer rather than the patch, which at this capacity + * would hold sixteen bodies of up to the byte ceiling each resident, and saves walking a patch + * of a hundred thousand lines again on every tick. */ const revisionPatches = yield* Cache.makeWith( (key: string) => { diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index fe6b68f35110..0bff1d300dbf 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -266,16 +266,10 @@ export interface ProviderFilesViewed { } /** - * What version each of the asked-for files is at, on the change request's head. - * - * Opaque strings, compared only against one another. The empty string is an answer rather than a - * gap, the one a file the change request deletes is at, so a mark taken against it stays cleared. - * - * A path is absent only when the read could not say: a host that answered for part of the change - * must leave the rest out rather than report it as deleted, or a file past the cut would be - * cleared once and cleared for good. So a provider converts its host's own absences on the way - * here: a path the read reached and found no version for arrives as the empty string rather - * than as a gap. + * What version each of the asked-for files is at, on the change request's head. Opaque strings, + * compared only against one another, where the empty string is the answer for a file the change + * request deletes rather than a gap. A path is absent only where the read could not say, so a + * provider converts its host's own absences on the way here. * * The whole path a version travels, and the three senses of null along it, are in * `docs/internals/pull-request-file-revisions.md`. @@ -285,11 +279,8 @@ export interface ProviderFileRevisions { /** * Whether these are every file the change request carries rather than only the paths asked * about. A host with no per-file version reads the whole change to answer for one file, and - * saying so is what keeps the next tick from making it read the whole change again: the caller - * holds what it is told, and a tick names a path nothing has asked about before. - * - * Only for an answer that can speak for the whole change. A read cut short part way through - * says nothing about what came after it, so it reports the paths it was asked about and no more. + * saying so is what keeps the next tick, naming a path nothing asked about before, from making + * it read the whole change again. False for a read cut short, which cannot speak past the cut. */ readonly complete?: boolean; } @@ -474,9 +465,8 @@ export interface PullRequestProviderApi { /** * Clears files, or puts them back. Only called when `capabilities.viewedFiles` is `"host"`. - * - * A provider whose host has no bulk form still owes one round trip for the batch rather than - * one per file, since the point of gathering them here is that the host is asked once. + * A host with no bulk form is still owed one round trip for the batch rather than one per file, + * since the point of gathering presses is that the host is asked once. */ readonly setFilesViewed?: ( input: ProviderRepositoryRef & { @@ -487,11 +477,10 @@ export interface PullRequestProviderApi { /** * What version the head has of each of these files. Required of a host whose - * `capabilities.viewedFiles` is `"environment"`, and unused by one that keeps the marks itself. - * - * The marks live here, but what counts as the same file does not: only the host can say whether - * what a reader cleared last week is still what is in front of them. Asked for the marked paths - * alone, so the cost follows how much of the change request has been read, not how large it is. + * `capabilities.viewedFiles` is `"environment"`, and unused by one that keeps the marks itself: + * the marks live here, but only the host can say whether what a reader cleared last week is + * still what is in front of them. Asked for the marked paths alone, so the cost follows how + * much of the change request has been read rather than how large it is. */ readonly getFileRevisions?: ( input: ProviderRepositoryRef & { diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 6a8a14cd55b9..7551baea85d7 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1611,10 +1611,9 @@ export const make = Effect.gen(function* () { * Who the host says the reader is, for the paths whose rows are keyed by it. A lookup that * failed is refused rather than answered as the unnamed reader: a momentarily signed-out CLI * would otherwise hide every tick this reader has made and file the next press under rows that - * are orphaned once it recovers. The reader is waiting on every one of these paths, a press or - * the boxes on a diff they just opened, so the lookup is let through a host's backoff rather - * than failing with it and turning a pause into a refusal. Scoped to here: the bypass is - * bounded by what the reader does, while a background read would repeat it on every refresh. + * are orphaned once it recovers. The reader is waiting on every one of these paths, so the + * lookup is let through a host's backoff rather than turning a pause into a refusal, and only + * here, where the bypass is bounded by what the reader does. */ const requiredViewerOf = ( project: SupportedProject, diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts index 42b1fb953e0a..aa5323c32fa6 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -134,11 +134,9 @@ function hunkRange(start: number, lines: number): string { /** * The `diff --git` preamble a viewer reads a file's identity and fate from. Azure reports no file - * mode, so the ordinary one stands in. - * - * Names are quoted the way git quotes them: a header reader stops a bare name at its first tab or - * newline, so a path holding either must be quoted or it gets truncated and the viewed mark lands - * on the wrong path. The `a/`/`b/` prefix goes inside the quoting, as git does it. + * mode, so the ordinary one stands in. Names are quoted the way git quotes them, prefix inside the + * quoting: a header reader stops a bare name at its first tab or newline, so a path holding either + * would be truncated and its viewed mark would land on the wrong file. */ function patchHeader(change: AzureDevOpsChangeEntry): string { const oldSide = quoteGitPatchPath(`a/${change.oldPath}`); diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts index fb3f021ce052..409013a1b2c9 100644 --- a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts @@ -14,11 +14,9 @@ export function toFileViewedStates( } /** - * Whether a file counts as seen. - * - * `dismissed` is the host saying it has been pushed to since the reader cleared it, which reads - * as unseen: the point of the tick is that the code behind it has been looked at, and it is not - * the same code any more. + * Whether a file counts as seen. `dismissed` is the host saying it has been pushed to since the + * reader cleared it, which reads as unseen: the point of the tick is that the code behind it has + * been looked at, and it is not the same code any more. */ function isViewedState(state: PullRequestFileViewedState | undefined): boolean { return state === "viewed"; @@ -51,19 +49,14 @@ export function countViewedFiles( } /** - * The overlay with everything the host has caught up on removed. - * - * A press is held locally until a read that could have seen it comes back, rather than cleared - * when the request succeeds: the read that follows a write is a separate round trip, and dropping - * the press in between would flash the checkbox back for as long as that took. + * The overlay with everything the host has caught up on removed. A press is held until a read that + * could have seen it comes back, rather than cleared when the write succeeds: that read is a + * separate round trip, and dropping the press in between flashes the checkbox back. * - * `pending` are the paths whose press the host cannot have heard yet, which an answer that was - * already on its way when they were pressed must not be allowed to overrule. - * - * `answered` are the paths a read has landed for since their write was acknowledged. Those go on - * that read alone, including where it contradicts the press: the host is the record of what has - * been looked at, and a tick held over a `dismissed` would hide a file pushed to since for as - * long as the tab stayed open, with no refresh able to recover it. + * `pending` are the paths whose press the host cannot have heard yet, which an answer already on + * its way must not overrule. `answered` are the paths a read has landed for since their write was + * acknowledged, and those go on that read alone, including where it contradicts the press: a tick + * held over a `dismissed` would hide a file pushed to since, with no refresh able to recover it. */ export function settleFileViewedOverlay( overlay: FileViewedOverlay, @@ -81,13 +74,10 @@ export function settleFileViewedOverlay( } /** - * The overlay with a failed request's presses taken back. - * - * `owned` are the paths that request still answers for, which is what keeps a failure from - * reaching past its own presses: a path pressed again since belongs to a later request or to the - * next flush, and putting that box back to the host's answer would take a press out from under - * the reader's hand. Even among those, a press is only taken back where the checkbox still shows - * it. + * The overlay with a failed request's presses taken back. `owned` are the paths that request still + * answers for, which keeps a failure from reaching past its own presses: a path pressed again + * since belongs to a later request or to the next flush, and putting that box back to the host's + * answer would take a press out from under the reader's hand. */ export function revertFileViewedOverlay( overlay: FileViewedOverlay, diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index 8e00d353d2e9..c2ee3f4c0bf4 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -50,12 +50,10 @@ export interface PullRequestFilesViewedView { } /** - * Which files this reader has already cleared. - * - * The marks live on the server rather than in this tab, so a review carried on from another - * machine picks up where it was left. Presses show immediately and are held over the server's - * answer until a read that could have seen them comes back, so the checkbox never waits on a - * round trip and never outlasts the record it stands in for. + * Which files this reader has already cleared. The marks live on the server rather than in this + * tab, so a review carried on from another machine picks up where it was left. Presses show + * immediately and are held over the server's answer until a read that could have seen them comes + * back, so the checkbox never waits on a round trip and never outlasts the record behind it. */ export function usePullRequestFilesViewed(options: { readonly environmentId: EnvironmentId; diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index 2b650eda1366..1ed25562fe6b 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -145,13 +145,10 @@ export function getRenderablePatch( } /** - * What the patch called the file, as the file's own name. - * - * Git writes a name holding a tab, a newline, a quote or a backslash quoted and escaped, and the - * parser hands one of those back still escaped. The name is what the rest of the app keys a file - * by and what it says to the server about one: a viewed mark, a review comment and a file's - * contents are all asked for by this path, and the host knows the file only under the name it - * really has. + * What the patch called the file, as the file's own name. Git writes a name holding a tab, a + * newline, a quote or a backslash quoted and escaped, and the parser hands one of those back still + * escaped. A viewed mark, a review comment and a file's contents are all asked for by this path, + * and the host knows the file only under the name it really has. */ function fileDiffPath(raw: string): string { const named = unquoteGitPatchPath(raw); diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 63620cfd23fa..6a6757ebeb12 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -258,20 +258,14 @@ export function createPullRequestEnvironmentAtoms( ]), }, }), - /** - * Which files this reader has already cleared, apart from the diff: the answer moves with - * every checkbox rather than with every push, and a patch of a few hundred files must not - * be re-fetched to learn that one box was ticked. - */ filesViewed: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:files-viewed", tag: WS_METHODS.pullRequestsFilesViewed, staleTimeMs: 15_000, }), /** - * One request per batch of presses, and one in flight per change request: the host applies - * these in order, and a reader ticking down a file list faster than the round trip would - * otherwise race their own presses. + * One write in flight per change request: the host applies these in order, and a reader + * ticking down a file list faster than the round trip would otherwise race their own presses. */ setFilesViewed: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:set-files-viewed", diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 9a02bb802faa..d118d4e240bf 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -370,12 +370,9 @@ export const PullRequestReviewerCapabilities = Schema.Struct({ export type PullRequestReviewerCapabilities = typeof PullRequestReviewerCapabilities.Type; /** - * Who remembers which files a reader has cleared. - * - * `host` is the host's own record, so the marks are the ones its web UI shows and a review can be - * carried on from either side. `environment` is this server's record, for a host that keeps no - * shared one: GitLab holds its viewed files in one browser's local storage, where nothing outside - * that browser can read or write them, so marks made here are this environment's own. + * Who remembers which files a reader has cleared. `host` is the host's own record, so the marks + * are the ones its web UI shows and a review can be carried on from either side. `environment` is + * this server's record, for a host that keeps none anything outside one browser can read. */ export const PullRequestViewedFilesStore = Schema.Literals(["host", "environment"]); export type PullRequestViewedFilesStore = typeof PullRequestViewedFilesStore.Type; @@ -418,8 +415,7 @@ export const PullRequestCapabilities = Schema.Struct({ reactions: Schema.optional(Schema.Boolean), /** * Where the reader's own marks are kept, or absent where they are kept nowhere and the - * checkbox is not offered at all. Optional for the same reason as `reactions`. Two answers - * rather than a flag, because the surface has to say which one it is. + * checkbox is not offered at all. Optional for the same reason as `reactions`. */ viewedFiles: Schema.optional(PullRequestViewedFilesStore), review: PullRequestReviewCapabilities, @@ -940,23 +936,21 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({ }); export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileContentsResult.Type; -// Not trimmed: a leading or trailing space is a legal part of a file's name, and both the patch -// and the environment's own record of what a reader cleared are keyed by the name the host gave. -// Trimming it here files the mark under a name nothing else uses, so the tick never comes back. /** - * Bounded because a path arrives from a client rather than from the host: unbounded, one element - * of a write batch could carry a megabyte into a SQL statement or a GraphQL field. Far past any - * real path, and short of anything worth holding. + * Bounded because a path arrives from a client rather than from the host: one element of a write + * batch could otherwise carry a megabyte into a SQL statement or a GraphQL field. */ const MAX_FILE_PATH_LENGTH = 4096; +/** + * Not trimmed: a leading or trailing space is a legal part of a file's name, and the mark is + * keyed by the name the host gave, so trimming files it under a name nothing else uses. + */ const FilePath = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(MAX_FILE_PATH_LENGTH)); /** - * Where one file of a change request stands with the person reading it. - * - * `dismissed` is the file that was cleared and has since been pushed to. Not `viewed`, since the - * reader has not seen what is there now, and not `unviewed`, which would lose the one thing worth - * telling them: this file and not the other forty is the one that moved. + * Where one file of a change request stands with the person reading it. `dismissed` is the file + * that was cleared and has since been pushed to, which is worth telling a reader apart from + * `unviewed`: this file and not the other forty is the one that moved. */ export const PullRequestFileViewedState = Schema.Literals(["unviewed", "viewed", "dismissed"]); export type PullRequestFileViewedState = typeof PullRequestFileViewedState.Type; @@ -968,11 +962,9 @@ export const PullRequestFileViewed = Schema.Struct({ export type PullRequestFileViewed = typeof PullRequestFileViewed.Type; /** - * Which files of a change request the reader has cleared, read apart from the diff itself. - * - * Its own read rather than a field on the patch: a patch changes when somebody pushes and is - * cached by the minute, this changes on every press. Carrying it on the diff would mean either - * forgetting a three-hundred-file patch per tick or showing a reader their own press as stale. + * Which files of a change request the reader has cleared. Its own read rather than a field on the + * patch: a patch changes when somebody pushes and is cached by the minute, this changes on every + * press, so one read would have to be wrong for the other to be right. */ export const PullRequestFilesViewedResult = Schema.Struct({ /** Only the files the host reported a state for. A file missing from this list is unviewed. */ @@ -983,17 +975,16 @@ export const PullRequestFilesViewedResult = Schema.Struct({ export type PullRequestFilesViewedResult = typeof PullRequestFilesViewedResult.Type; /** - * Files to clear, or to put back. Several at once because a reader working down a diff ticks - * boxes far faster than a host answers, so a burst is gathered into one request. - */ -/** - * How many presses one write carries. A burst is what a reader ticked in the last few hundred - * milliseconds, and every element of it is a statement of its own inside one transaction here, or - * a field of its own in one GraphQL document on GitHub. Matched to what a read of the marks - * carries, so a client cannot write more of them than it can ever read back. + * How many presses one write carries. Every element is a statement of its own inside one + * transaction here, or a field of its own in one GraphQL document on GitHub. Matched to what a + * read of the marks carries, so a client cannot write more of them than it can ever read back. */ const MAX_FILES_VIEWED_PRESSES = 500; +/** + * Files to clear, or to put back. Several at once because a reader working down a diff ticks + * boxes far faster than a host answers, so a burst is gathered into one request. + */ export const PullRequestSetFilesViewedInput = Schema.Struct({ ...PullRequestRef.fields, files: Schema.Array( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 197f4ec06389..91f01ee25e8d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -718,11 +718,6 @@ const WsPullRequestsDiffFileContentsRpc = Rpc.make(WS_METHODS.pullRequestsDiffFi error: PullRequestRpcError, }); -/** - * Which files the reader has already cleared. Its own call rather than a field on the diff: the - * patch is cached by the minute and this moves on every press of a checkbox, so sharing a read - * would make one of the two wrong. - */ const WsPullRequestsFilesViewedRpc = Rpc.make(WS_METHODS.pullRequestsFilesViewed, { payload: PullRequestRef, success: PullRequestFilesViewedResult, diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index b6d6be0396e1..0c7279551751 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -122,7 +122,6 @@ function azureDevOpsRepositoryKey(host: string, segments: ReadonlyArray) const [marker, organization, project, repository] = segments; if (segments.length !== 4 || marker !== "v3") return null; if (!organization || !project || !repository) return null; - // The organization leads the host on the name dev.azure.com replaced, and the path below it. return host === "ssh.dev.azure.com" ? `dev.azure.com/${organization}/${project}/_git/${repository}` : `${organization}.visualstudio.com/${project}/_git/${repository}`; diff --git a/packages/shared/src/gitPatchPath.ts b/packages/shared/src/gitPatchPath.ts index 4141f7deec73..767600c4a4b2 100644 --- a/packages/shared/src/gitPatchPath.ts +++ b/packages/shared/src/gitPatchPath.ts @@ -1,22 +1,16 @@ /** - * How a file's name travels in a unified patch, which is not as itself. - * - * A patch's headers are lines with the name inside them, and the reader finds the name by where - * it stops: `diff --git a/ b/` splits on a space, `--- a/` stops at a tab. So a - * name holding a tab or a newline reads back as the part of itself before that byte, or fabricates - * a header line of its own. Git's answer is to write such a name quoted, with C-style escapes, and - * every reader of the format knows the form. - * - * Both halves live together so that what one writes is what the other reads. + * How a file's name travels in a unified patch, which is not as itself. A reader finds the name in + * a header by where it stops: `diff --git a/ b/` splits on a space, `--- a/` stops + * at a tab. So a name holding a tab or a newline reads back as the part of itself before that + * byte, or fabricates a header line of its own, and git's answer is to write such a name quoted + * with C-style escapes. Both halves live here so what one writes is what the other reads. */ /** - * What git escapes by name, as the character written and the escape written for it. - * - * Not every byte git would escape: it also escapes anything outside ASCII when `core.quotePath` is - * on, which is a setting for what a terminal can show rather than anything the format needs. A - * patch here is read by a diff viewer, so a name in another alphabet is left as itself and arrives - * legible. + * What git escapes by name, as the character written and the escape written for it. Not every byte + * git would escape: `core.quotePath` also escapes anything outside ASCII, which is a setting for + * what a terminal can show rather than anything the format needs, and a patch here is read by a + * diff viewer. */ const ESCAPE_BY_CHARACTER = new Map([ ['"', '\\"'], @@ -53,13 +47,9 @@ const fromUtf8 = new TextDecoder(); /** * A name as a patch header can carry it: itself where that is unambiguous, and git's quoted form - * where it is not. The name a reader of the header gets back is the name that went in. - * - * A quote or a backslash is what the quoting is written with, and a control character either stops - * the reader short or starts a line the patch never had, so a name holding any of them is quoted. - * - * A header side's `a/` or `b/` belongs inside the quoting, so pass it in along with the name: what - * git quotes is the whole token a reader takes off the line, side letter and all. + * where it is not, which is any name holding a quote, a backslash or a control character. A header + * side's `a/` or `b/` belongs inside the quoting, so pass it in along with the name: what git + * quotes is the whole token a reader takes off the line, side letter and all. */ export function quoteGitPatchPath(path: string): string { let body = ""; @@ -85,15 +75,10 @@ export function quoteGitPatchPath(path: string): string { } /** - * The escapes inside a quoted form undone, whether or not the quotes are still around them. - * - * A name holding no backslash at all is already itself and is handed straight back, which is what - * keeps an unquoted name out of this: git quotes any name with a backslash in it, so a name that - * arrived unquoted has no escape to undo. An escape git would never write reads the way C reads - * it, as the character behind the backslash. - * - * The escapes are per byte, so a name in any other alphabet arrives as a run of octal and only - * reads back as itself once those bytes are rejoined and decoded together. + * The escapes inside a quoted form undone, whether or not the quotes are still around them. A name + * holding no backslash is already itself, and an escape git would never write reads the way C + * reads it. The escapes are per byte, so a name in another alphabet arrives as a run of octal and + * only reads back as itself once those bytes are rejoined and decoded together. */ function unescapeBody(body: string): string { if (!body.includes("\\")) return body; @@ -142,13 +127,10 @@ function unescapeBody(body: string): string { } /** - * One header's name token as the name it stands for. - * - * The quoting comes off where it is there, and the escapes are undone either way: patch parsers - * disagree about how much of the quoting they hand back, and the one the clients read diffs with - * takes the quotes off the `diff --git` line's names and leaves them on a rename's. A name a - * producer wrote unquoted never carries an escape to undo, so reading it for them costs it - * nothing. + * One header's name token as the name it stands for. The escapes are undone whether the quotes are + * still there or not, because patch parsers disagree about how much of the quoting they hand back: + * the one the clients read diffs with takes the quotes off the `diff --git` line's names and + * leaves them on a rename's. */ export function unquoteGitPatchPath(token: string): string { if (token.length >= 2 && token.startsWith(QUOTE) && token.endsWith(QUOTE)) { From c98d31bc815a8ecd515a3e7b63699cc2b8f3cd39 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 12 Sep 2026 16:04:04 -0400 Subject: [PATCH 66/78] test(server): pin the viewer lookup a paused host is not asked to repeat Nothing held the case the pause exists for: a lookup that failed is remembered nowhere, so a background read let through would spawn the host's CLI on every refresh and keep extending the pause it was already under. Signed-off-by: Yordis Prieto --- .../pullRequest/PullRequestService.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 611e73de85d2..56da059efecb 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -5541,6 +5541,63 @@ it.effect("asks who is reading through a pause only for the press that is waitin }), ); +it.effect("does not ask a paused host who is reading again after the ask failed", () => + Effect.gen(function* () { + let viewerLookups = 0; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + { + ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), + getViewer: () => + Effect.suspend(() => { + viewerLookups += 1; + return Effect.fail( + new PullRequestProviderError({ + provider: "gitlab", + operation: "getViewer", + reason: "failed", + detail: "glab exited with status 1", + }), + ); + }), + listChangeRequests: () => + Effect.succeed({ items: [], truncated: false, continues: true }), + runAction: () => + Effect.fail( + new PullRequestProviderError({ + provider: "gitlab", + operation: "runAction", + reason: "rate-limited", + detail: "API rate limit exceeded.", + retryAt: 60 * 60 * 1_000, + }), + ), + }, + ], + }); + + yield* Effect.flip(service.filesViewed(GITLAB_REFERENCE)); + assert.strictEqual(viewerLookups, 1); + yield* Effect.flip(service.runAction({ ...GITLAB_REFERENCE, action: "merge" })); + + // A failed lookup is held nowhere, so a background read let through the pause would spawn the + // host's CLI on every refresh for as long as the pause lasted, and re-extend it each time. + yield* Effect.flip(service.list({ state: "open", involvement: "all" })); + yield* TestClock.adjust("11 minutes"); + yield* Effect.flip(service.list({ state: "open", involvement: "all" })); + assert.strictEqual(viewerLookups, 1); + }), +); + it.effect("refuses the marks when the host could not be asked who is reading", () => Effect.gen(function* () { let answering = true; From 0d6474006c3aeaa4fb2a1e59de7dbcb10ec83932 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 14 Sep 2026 11:08:22 -0400 Subject: [PATCH 67/78] test(mobile): a cold highlighter is not held to a warm one's tokens Signed-off-by: Yordis Prieto --- .../review/shikiReviewHighlighter.test.ts | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts index cfb28051cb12..f66b34675970 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts @@ -5,6 +5,7 @@ import { highlightCodeSnippet, highlightReviewSelectedLines, highlightSourceFile, + type ReviewHighlightedToken, } from "./shikiReviewHighlighter"; describe("highlightSourceFile", () => { @@ -51,23 +52,31 @@ describe("highlightSourceFile", () => { vi.resetModules(); const highlighter = await import("./shikiReviewHighlighter"); const source = "const answer: number = 42;"; - - const highlighted = await highlighter.highlightSourceFile({ - path: "example.ts", - contents: source, - theme: "dark", - }); - - expect( - highlighted - .flat() - .map((token) => token.content) - .join(""), - ).toBe(source); - expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); - expect( + // Each entry point is held to the source text and to having highlighted it, rather than to + // the other's tokens. Both ask for the same language and theme, so comparing the two only + // pins how much of the grammar the engine had compiled by the time each one ran. + const expectHighlighted = ( + tokenLines: ReadonlyArray>, + ) => { + expect( + tokenLines + .flat() + .map((token) => token.content) + .join(""), + ).toBe(source); + expect(tokenLines.flat().some((token) => token.color !== null)).toBe(true); + }; + + expectHighlighted( + await highlighter.highlightSourceFile({ + path: "example.ts", + contents: source, + theme: "dark", + }), + ); + expectHighlighted( await highlighter.highlightCodeSnippet({ code: source, language: "ts", theme: "dark" }), - ).toEqual(highlighted); + ); }); }); From 042e3c6d1d337da5279e40ab5069952f9baf07f5 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 14 Sep 2026 12:03:54 -0400 Subject: [PATCH 68/78] fix(server): a viewed mark on a wide review stops reporting changes A whole-change answer wider than one entry's path cap trimmed away the reader's own files, which stored the mark without a baseline and left it reading viewed however far the head moved. Signed-off-by: Yordis Prieto --- .../pullRequest/PullRequestService.test.ts | 52 +++++++++++++++++++ .../src/pullRequest/pullRequestViewedFiles.ts | 4 +- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 85fee278d97b..0bb2a16fb546 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -5594,6 +5594,58 @@ it.effect("bounds the paths one change request's held revisions carry", () => }), ); +it.effect("keeps the marked paths when a whole-change answer is wider than the cap", () => + Effect.gen(function* () { + // A host with no per-file version answers with the whole change, which on a wide review + // carries more paths than one entry holds. What the trim reaches has to be the paths the + // answer threw in rather than the ones the reader ticked: a mark stored with no baseline + // reports viewed however far the head moves off it. + const head = new Map( + Array.from( + { length: MAX_FILE_REVISION_PATHS + 178 }, + (_, index) => + [`src/f${String(index).padStart(4, "0")}.ts`, `blob-${String(index)}`] as const, + ), + ); + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + { + ...environmentViewedProvider(head, []), + getFileRevisions: () => Effect.succeed({ revisions: head, complete: true }), + }, + ], + }); + const ticked = ["src/f0000.ts", "src/f0500.ts"]; + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: ticked.map((path) => ({ path, viewed: true })), + }); + for (const path of ticked) head.set(path, "blob-moved"); + // Past the stale window, so the read is answered by the host rather than from what the press + // heard. + yield* TestClock.adjust("11 minutes"); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual( + [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { path: "src/f0000.ts", state: "dismissed" }, + { path: "src/f0500.ts", state: "dismissed" }, + ], + ); + }), +); + it.effect("keeps the change request being ticked through, not the one pressed first", () => Effect.gen(function* () { // Ordered by insertion alone a hit does not renew its entry, so the review a reader is diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index 10374b19c4ab..e8ccd8dd6be0 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -95,7 +95,9 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { : null; const revisions = new Map(carried?.revisions ?? []); const asked = new Set(carried?.asked ?? []); - const learned = answer.complete === true ? [...paths, ...answer.revisions.keys()] : paths; + // The paths asked for go last, so a whole-change answer wider than the cap is trimmed down + // to the reader's own files rather than over them. + const learned = answer.complete === true ? [...answer.revisions.keys(), ...paths] : paths; for (const path of learned) { // Reinserted rather than added, so what a full entry drops below is the path nobody has // asked about in the longest rather than one just asked for. From e3ce1266b7695b74874fae895b074f7e834aa131 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 16 Sep 2026 10:36:19 -0400 Subject: [PATCH 69/78] revert(mobile): drop the highlighter test rewrite from this branch The equality it dropped guards a test this change does not otherwise touch, and the CI flake behind the rewrite is load-sensitive rather than a property of viewed marks. Both belong in their own change. Signed-off-by: Yordis Prieto --- .../review/shikiReviewHighlighter.test.ts | 41 ++++++++----------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts index f66b34675970..cfb28051cb12 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts @@ -5,7 +5,6 @@ import { highlightCodeSnippet, highlightReviewSelectedLines, highlightSourceFile, - type ReviewHighlightedToken, } from "./shikiReviewHighlighter"; describe("highlightSourceFile", () => { @@ -52,31 +51,23 @@ describe("highlightSourceFile", () => { vi.resetModules(); const highlighter = await import("./shikiReviewHighlighter"); const source = "const answer: number = 42;"; - // Each entry point is held to the source text and to having highlighted it, rather than to - // the other's tokens. Both ask for the same language and theme, so comparing the two only - // pins how much of the grammar the engine had compiled by the time each one ran. - const expectHighlighted = ( - tokenLines: ReadonlyArray>, - ) => { - expect( - tokenLines - .flat() - .map((token) => token.content) - .join(""), - ).toBe(source); - expect(tokenLines.flat().some((token) => token.color !== null)).toBe(true); - }; - - expectHighlighted( - await highlighter.highlightSourceFile({ - path: "example.ts", - contents: source, - theme: "dark", - }), - ); - expectHighlighted( + + const highlighted = await highlighter.highlightSourceFile({ + path: "example.ts", + contents: source, + theme: "dark", + }); + + expect( + highlighted + .flat() + .map((token) => token.content) + .join(""), + ).toBe(source); + expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); + expect( await highlighter.highlightCodeSnippet({ code: source, language: "ts", theme: "dark" }), - ); + ).toEqual(highlighted); }); }); From ac0da0755be2ab8fcbb1c506619a511fdfd03177 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 16 Sep 2026 18:49:04 -0300 Subject: [PATCH 70/78] fix(web): preserve both imports for main integration --- apps/web/src/components/ChatMarkdown.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 82eb0be0f4aa..eeae7bf03de2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -34,6 +34,7 @@ import type { ThreadPullRequestKey, } from "@t3tools/contracts"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; +import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { isAtomCommandInterrupted, From 4695b9a084f23c3413b9fa15eb8f60613fa1aee7 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 16 Sep 2026 18:49:27 -0300 Subject: [PATCH 71/78] fix(web): reconcile markdown changes with main --- apps/web/src/components/ChatMarkdown.tsx | 99 +++++++++++++++++++++--- 1 file changed, 87 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index eeae7bf03de2..d8b24a70117b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -222,6 +222,9 @@ interface ChatMarkdownProps { extraRemarkPlugins?: NonNullable; /** Renders a `t3-context://` link as a chip; without it the link shows its label as text. */ renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined; + /** Loads GitHub-hosted media through `cwd`'s GitHub credential, which a private repository's + uploads need; without it those images and videos load unauthenticated and 404. */ + githubMedia?: boolean | undefined; /** Levels added to each markdown heading in the accessibility tree so the text nests under the heading that introduces it, such as a chat message's author. Rendered tags and their styling are unchanged. */ @@ -1575,7 +1578,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props readonly environmentId: EnvironmentId; readonly resource: Extract< AssetResource, - { readonly _tag: "attachment" | "workspace-file" | "media-file" } + { readonly _tag: "attachment" | "workspace-file" | "media-file" | "github-media" } >; readonly kind?: "image" | "video"; readonly alt: string; @@ -1586,6 +1589,18 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props /** Caps the box height in rem while keeping the image's ratio; 30 by default. */ readonly maxHeightRem?: number | undefined; readonly style?: CSSProperties | undefined; + readonly className?: string | undefined; + /** Sanitized authored attributes (`id`, `align`, …) that fragment links and layout rely on. */ + readonly imageProps?: + | Omit, "src" | "alt" | "className" | "style"> + | undefined; + /** Where the media also lives on the web, for the failure state's escape hatch. */ + readonly originalUrl?: string | undefined; + /** The workspace media frame, on by default; off for media that keeps the author's own box. */ + readonly framed?: boolean | undefined; + /** Loaded instead of the failure state when no URL can be signed, such as against a server + too old to know this resource. Only safe when the client can reach it directly. */ + readonly fallbackSrc?: string | undefined; readonly workspaceRoot?: string | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { @@ -1598,9 +1613,19 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props : resource._tag === "workspace-file" && props.workspaceRoot ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` : undefined; - const reference = path ? mediaFileReference(path, props.workspaceRoot) : undefined; - const relativePath = reference?.relativePath; - const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null; + const reference = path + ? mediaFileReference(path, props.workspaceRoot) + : props.originalUrl + ? mediaUrlReference(props.originalUrl) + : undefined; + const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; + const fallbackSrc = assetUrl._tag === "Failure" ? props.fallbackSrc : undefined; + const src = + assetUrl._tag === "Success" + ? assetUrl.url + (props.srcFragment ?? "") + : fallbackSrc === undefined + ? null + : fallbackSrc + (props.srcFragment ?? ""); // The server reads the pixel size from the file header, so the slot can be // the image's final box instead of a 16:9 guess. An authored size wins; a // caller's height cap shrinks the box while keeping the ratio. @@ -1617,9 +1642,11 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props kind: props.kind ?? "image", name: props.alt || (props.kind ?? "image"), src, - asset: { environmentId: props.environmentId, resource }, + ...(fallbackSrc === undefined + ? { asset: { environmentId: props.environmentId, resource } } + : {}), ...(reference ? { reference } : {}), - ...(relativePath && resource._tag !== "attachment" + ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file") ? { onOpenFile: () => useRightPanelStore @@ -1636,9 +1663,10 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props return ( ); @@ -2228,6 +2261,7 @@ function useChatMarkdownState({ onImageExpand, renderContextReference, headingLevelOffset = 0, + githubMedia = false, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const [localMediaPreview, setLocalMediaPreview] = useState(null); @@ -2625,6 +2659,7 @@ function useChatMarkdownState({ environmentId, expandMedia, fileLinkChip, + githubMedia, renderContextReference, headingLevelOffset, imageBaseDir, @@ -2654,6 +2689,7 @@ function useChatMarkdownState({ environmentId, expandMedia, fileLinkChip, + githubMedia, renderContextReference, headingLevelOffset, imageBaseDir, @@ -3083,9 +3119,15 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, img: function MarkdownImage({ node, title, src, alt, ...props }) { - const { expandMedia, cwd, imageBaseDir, threadRef, renderContextReference } = use( - ChatMarkdownRendererContext, - ); + const { + expandMedia, + cwd, + environmentId, + githubMedia, + imageBaseDir, + threadRef, + renderContextReference, + } = use(ChatMarkdownRendererContext); const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; const contextReference = typeof src === "string" ? parseComposerContextHref(src) : null; if (contextReference) { @@ -3111,6 +3153,39 @@ const CHAT_MARKDOWN_COMPONENTS = { const authoredSizeStyle = authoredImageSizeStyle(width, height); const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); const kind = mediaKindFromPath(classifiedSrc) ?? "image"; + const directUri = imageSource._tag === "Direct" ? imageSource.uri : null; + const githubMediaUrl = + directUri === null ? null : githubMediaFetchUrl(resolveProtocolRelativeMediaUrl(directUri)); + if ( + githubMedia && + cwd !== undefined && + environmentId !== null && + directUri !== null && + githubMediaUrl !== null + ) { + return ( + + ); + } if (imageSource._tag === "Direct") { const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri); const originalUrl = From bcdb951d44d62582759efb255861fb9ed89f8396 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 16 Sep 2026 18:51:11 -0300 Subject: [PATCH 72/78] fix(review): align conflicting files before main integration --- .../mobile/src/features/review/reviewModel.ts | 52 ++++++++++++------- apps/web/src/components/ChatMarkdown.tsx | 3 +- apps/web/src/lib/diffRendering.ts | 16 +----- 3 files changed, 36 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index f3adee73382b..2cb8c61a1610 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -1,7 +1,6 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts"; -import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import * as Order from "effect/Order"; @@ -19,6 +18,9 @@ export interface ReviewSectionItem { readonly subtitle: string | null; readonly diff: string | null; readonly isLoading: boolean; + readonly files?: ReviewDiffPreviewSource["files"]; + readonly truncated?: boolean; + readonly source?: ReviewDiffPreviewSource; } export interface ReviewRenderableHunkRow { @@ -48,6 +50,7 @@ export type ReviewRenderableRow = ReviewRenderableHunkRow | ReviewRenderableLine export interface ReviewRenderableFile { readonly id: string; readonly cacheKey: string; + readonly notice?: string; readonly path: string; readonly previousPath: string | null; readonly changeType: ChangeTypes; @@ -127,22 +130,6 @@ function gitSubtitle(section: ReviewDiffPreviewSource): string | null { return "Base branch unavailable"; } -/** - * The file's own name, given the patch wrote it the way git writes one: a name holding a tab, a - * newline, a quote or a backslash arrives quoted and escaped, and the parser hands one of those - * back still escaped. - */ -function stripGitPrefix(pathValue: string | undefined): string | null { - if (!pathValue) { - return null; - } - const named = unquoteGitPatchPath(pathValue); - if (named.startsWith("a/") || named.startsWith("b/")) { - return named.slice(2); - } - return named; -} - function stripTrailingNewline(value: string): string { return value.endsWith("\n") ? value.slice(0, -1) : value; } @@ -385,8 +372,8 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray total + hunk.additionLines, 0); const deletions = file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0); const cacheKey = file.cacheKey ?? `${previousPath ?? "none"}:${path}:${file.type}`; @@ -449,6 +436,9 @@ export function buildReviewSectionItems(input: { title: section.title, subtitle: gitSubtitle(section), diff: section.diff, + source: section, + ...(section.files ? { files: section.files } : {}), + truncated: section.truncated, isLoading: false, })); const hasDirtyWorktreeItem = gitItems.some((item) => item.id === DIRTY_WORKTREE_SECTION_ID); @@ -534,3 +524,27 @@ export function buildReviewParsedDiff( }; } } + +export function applyReviewDiffMetadata( + previewDiff: ReviewParsedDiff, + selectedSection: Pick | null, +): ReviewParsedDiff { + if (previewDiff.kind === "empty") return previewDiff; + const notice = selectedSection?.truncated + ? `This preview exceeds the size limit. Changes shown are incomplete.${selectedSection.files ? " Counts include all changes." : ""}` + : previewDiff.notice; + if (previewDiff.kind !== "files" || !selectedSection?.files) return { ...previewDiff, notice }; + const totals = selectedSection.files.reduce( + (total, file) => ({ + additions: total.additions + file.additions, + deletions: total.deletions + file.deletions, + }), + { additions: 0, deletions: 0 }, + ); + const stats = new Map(selectedSection.files.map((file) => [file.path, file])); + const files = previewDiff.files.map((file) => { + const stat = stats.get(file.path); + return stat ? { ...file, additions: stat.additions, deletions: stat.deletions } : file; + }); + return { ...previewDiff, ...totals, files, fileCount: selectedSection.files.length, notice }; +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index d8b24a70117b..4e80768b315a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -35,7 +35,6 @@ import type { } from "@t3tools/contracts"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -2906,7 +2905,7 @@ const CHAT_MARKDOWN_COMPONENTS = { input: { projectId: pullRequestProject.id, repository: - sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ?? + pullRequestProject.repositoryIdentity?.displayName ?? pullRequestCandidate.repository, number: pullRequestCandidate.number, }, diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index 1ed25562fe6b..2af48db53a8f 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -1,6 +1,5 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { FileDiffMetadata } from "@pierre/diffs/types"; -import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; const DIFF_THEME_NAMES = { light: "pierre-light", @@ -144,19 +143,8 @@ export function getRenderablePatch( } } -/** - * What the patch called the file, as the file's own name. Git writes a name holding a tab, a - * newline, a quote or a backslash quoted and escaped, and the parser hands one of those back still - * escaped. A viewed mark, a review comment and a file's contents are all asked for by this path, - * and the host knows the file only under the name it really has. - */ -function fileDiffPath(raw: string): string { - const named = unquoteGitPatchPath(raw); - return named.startsWith("a/") || named.startsWith("b/") ? named.slice(2) : named; -} - export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { - return fileDiffPath(fileDiff.name ?? fileDiff.prevName ?? ""); + return fileDiff.name ?? fileDiff.prevName ?? ""; } /** @@ -164,7 +152,7 @@ export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { * path, and the hosts that resolve a diff position against both sides need both names. */ export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string { - return fileDiffPath(fileDiff.prevName ?? fileDiff.name ?? ""); + return fileDiff.prevName ?? fileDiff.name ?? ""; } export function buildFileDiffIdentityKey(fileDiff: FileDiffMetadata): string { From 4dc915564ea8a272e243a85193e8cc3a3ddc773d Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 16 Sep 2026 18:51:46 -0300 Subject: [PATCH 73/78] fix(review): preserve decoded paths after main integration --- apps/mobile/src/features/review/reviewModel.ts | 5 +++-- apps/web/src/components/ChatMarkdown.tsx | 3 ++- apps/web/src/lib/diffRendering.ts | 15 +++++++++++++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 2cb8c61a1610..06fe4c9597d9 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -1,6 +1,7 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts"; +import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import * as Order from "effect/Order"; @@ -372,8 +373,8 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray total + hunk.additionLines, 0); const deletions = file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0); const cacheKey = file.cacheKey ?? `${previousPath ?? "none"}:${path}:${file.type}`; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4e80768b315a..9136900fe0f7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -53,6 +53,7 @@ import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-li import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { Children, @@ -2905,7 +2906,7 @@ const CHAT_MARKDOWN_COMPONENTS = { input: { projectId: pullRequestProject.id, repository: - pullRequestProject.repositoryIdentity?.displayName ?? + sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ?? pullRequestCandidate.repository, number: pullRequestCandidate.number, }, diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index 2af48db53a8f..257b4e7f8d6d 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -1,5 +1,6 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { FileDiffMetadata } from "@pierre/diffs/types"; +import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; const DIFF_THEME_NAMES = { light: "pierre-light", @@ -143,8 +144,18 @@ export function getRenderablePatch( } } +/** + * What the patch called the file, as the file's own name. Git writes a name holding a tab, a + * newline, a quote or a backslash quoted and escaped, and the parser hands one of those back still + * escaped. A viewed mark, a review comment and a file's contents are all asked for by this path, + * and the host knows the file only under the name it really has. + */ +function fileDiffPath(raw: string): string { + return unquoteGitPatchPath(raw); +} + export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { - return fileDiff.name ?? fileDiff.prevName ?? ""; + return fileDiffPath(fileDiff.name ?? fileDiff.prevName ?? ""); } /** @@ -152,7 +163,7 @@ export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { * path, and the hosts that resolve a diff position against both sides need both names. */ export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string { - return fileDiff.prevName ?? fileDiff.name ?? ""; + return fileDiffPath(fileDiff.prevName ?? fileDiff.name ?? ""); } export function buildFileDiffIdentityKey(fileDiff: FileDiffMetadata): string { From b4e5c21d348a01ef9cfb3d2a76b67780271c9031 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 16 Sep 2026 19:06:17 -0300 Subject: [PATCH 74/78] fix(prs): preserve viewed behavior across settings and routed hosts --- .../pullRequest/PullRequestService.test.ts | 32 +++++++- .../src/pullRequest/PullRequestService.ts | 33 +++----- .../pullRequest/gitHubPullRequestJson.test.ts | 51 ++++++++++++ .../src/pullRequest/gitHubPullRequestJson.ts | 16 ++-- .../gitLabMergeRequestJson.test.ts | 51 ++++++++++++ .../src/pullRequest/gitLabMergeRequestJson.ts | 12 ++- apps/server/src/ws.ts | 10 ++- .../pullRequest/PullRequestCodeTab.tsx | 15 ++-- .../src/state/pullRequestRouting.ts | 13 +++- .../src/state/pullRequests.test.ts | 78 +++++++++++++++++++ .../client-runtime/src/state/pullRequests.ts | 2 + packages/contracts/src/pullRequest.ts | 2 + 12 files changed, 266 insertions(+), 49 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 98fe7031ff28..1460ae6ae04b 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3881,7 +3881,7 @@ it.effect("shares linked summaries and reuses them for display without asking th it.effect("keeps routed reads separate when the GitHub account changes", () => Effect.gen(function* () { - for (const operation of ["summary", "detail", "diff"] as const) { + for (const operation of ["summary", "detail", "diff", "filesViewed"] as const) { let failing = false; let calls = 0; const read = () => @@ -3897,6 +3897,14 @@ it.effect("keeps routed reads separate when the GitHub account changes", () => ], providers: [ fakeProvider("github", { + capabilities: { ...fakeProvider("github").capabilities, viewedFiles: "host" }, + getFilesViewed: () => + read().pipe( + Effect.as({ + files: [{ path: "private.ts", state: "viewed" as const }], + truncated: false, + }), + ), getChangeRequestSummary: read, getChangeRequest: read, getDiff: () => @@ -3927,7 +3935,7 @@ it.effect("keeps routed reads separate when the GitHub account changes", () => it.effect("isolates routed caches for two credentials belonging to the same account", () => Effect.gen(function* () { - for (const operation of ["summary", "detail", "diff"] as const) { + for (const operation of ["summary", "detail", "diff", "filesViewed"] as const) { let credential = "broad"; let calls = 0; const read = () => @@ -3943,6 +3951,14 @@ it.effect("isolates routed caches for two credentials belonging to the same acco ], providers: [ fakeProvider("github", { + capabilities: { ...fakeProvider("github").capabilities, viewedFiles: "host" }, + getFilesViewed: () => + read().pipe( + Effect.as({ + files: [{ path: "private.ts", state: "viewed" as const }], + truncated: false, + }), + ), withVerifiedCredential: (_, use) => Effect.suspend(() => use({ @@ -4972,6 +4988,7 @@ it.effect("keeps the diff cached across a file being ticked off", () => Effect.gen(function* () { let diffReads = 0; let viewedReads = 0; + let state: "viewed" | "dismissed" = "viewed"; const service = yield* makeService({ projects: [ project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), @@ -4996,7 +5013,7 @@ it.effect("keeps the diff cached across a file being ticked off", () => getFilesViewed: () => { viewedReads += 1; return Effect.succeed({ - files: [{ path: "src/a.ts", state: "viewed" as const }], + files: [{ path: "src/a.ts", state }], truncated: false, }); }, @@ -5015,6 +5032,15 @@ it.effect("keeps the diff cached across a file being ticked off", () => // The press forgets only the reader's own ticks: a diff of any size survives it. assert.strictEqual(diffReads, 1); assert.strictEqual(viewedReads, 2); + + state = "dismissed"; + yield* service.invalidate({ reference, filesViewedOnly: true }); + yield* service.diff(reference); + assert.deepStrictEqual((yield* service.filesViewed(reference)).files, [ + { path: "src/a.ts", state: "dismissed" }, + ]); + assert.strictEqual(diffReads, 1); + assert.strictEqual(viewedReads, 3); }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 76478a5208cc..4a2bebccfae5 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -2908,15 +2908,8 @@ export const make = Effect.gen(function* () { const filesViewedCache = yield* Cache.makeWith( (key: string) => { - const [, , projectId, host, repository, number] = JSON.parse(key) as [ - number, - number, - string, - string, - string, - number, - ]; - return viewedFiles.filesViewed({ projectId, host, repository, number } as PullRequestRef); + const [referenceKey] = JSON.parse(key) as [string, number]; + return viewedFiles.filesViewed(refOfCacheKey(referenceKey)); }, { capacity: FILES_VIEWED_CACHE_CAPACITY, @@ -2928,17 +2921,7 @@ export const make = Effect.gen(function* () { const filesViewed: PullRequestService["Service"]["filesViewed"] = (input) => canonicalRef(input).pipe( Effect.flatMap((ref) => - Cache.get( - filesViewedCache, - JSON.stringify([ - refEpoch(ref), - filesViewedEpoch(ref), - ref.projectId, - ref.host, - ref.repository, - ref.number, - ]), - ), + Cache.get(filesViewedCache, JSON.stringify([refCacheKey(ref), filesViewedEpoch(ref)])), ), ); @@ -3001,6 +2984,14 @@ export const make = Effect.gen(function* () { const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { const reference = input.reference; + if (input.filesViewedOnly === true) { + return reference === undefined + ? Cache.invalidateAll(filesViewedCache) + : canonicalRef(reference).pipe( + Effect.flatMap((ref) => Effect.sync(() => bumpFilesViewedEpoch(ref))), + Effect.ignore, + ); + } if (reference !== undefined) { return canonicalRef(reference).pipe( Effect.flatMap((ref) => @@ -3126,7 +3117,7 @@ export const make = Effect.gen(function* () { threadComments, diff: credentialCached(diff), diffFileContents, - filesViewed, + filesViewed: credentialCached(filesViewed), setFilesViewed, runAction: runActionAndInvalidate, update: invalidatedByMutation(update), diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index ea406adf4ced..3740fdca00f1 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -1350,6 +1350,57 @@ describe("review submission payload", () => { }); describe("decodePullRequestFilesJson", () => { + it("quotes literal backslashes without interpreting them as escapes", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { + filename: String.raw`src\notes.ts`, + status: "modified", + patch: "@@ -1 +1 @@\n-old\n+new", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`, + String.raw`--- "a/src\\notes.ts"`, + String.raw`+++ "b/src\\notes.ts"`, + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + }); + + it("preserves spaces and literal backslashes in both rename paths", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { + previous_filename: String.raw` old\name.ts `, + filename: String.raw` new\name.ts `, + status: "renamed", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`, + String.raw`rename from " old\\name.ts "`, + String.raw`rename to " new\\name.ts "`, + String.raw`--- "a/ old\\name.ts "`, + String.raw`+++ "b/ new\\name.ts "`, + "", + ].join("\n"), + ); + }); + it("assembles a unified patch the files API does not return", () => { const result = expectSuccess( decodePullRequestFilesJson( diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index e04a63bf3a47..13bbe86a81fc 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -31,6 +31,7 @@ import type { PullRequestState, PullRequestThreadComment, } from "@t3tools/contracts"; +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { dedupeChecks } from "./pullRequestChecks.ts"; @@ -2497,16 +2498,21 @@ export function decodePullRequestFilesJson( } // A rename counts its hunks against the old path, which is the only place it is named. const oldPath = - status === "renamed" ? (trimmed(value.previous_filename) ?? value.filename) : value.filename; + status === "renamed" ? value.previous_filename || value.filename : value.filename; const header = [ - `diff --git a/${oldPath} b/${value.filename}`, + `diff --git ${quoteGitPatchPath(`a/${oldPath}`)} ${quoteGitPatchPath(`b/${value.filename}`)}`, // The files API reports no file mode, so the ordinary one stands in: the viewer reads // these lines as "added" and "removed" rather than for the mode they carry. ...(status === "added" ? ["new file mode 100644"] : []), ...(status === "removed" ? ["deleted file mode 100644"] : []), - ...(status === "renamed" ? [`rename from ${oldPath}`, `rename to ${value.filename}`] : []), - `--- ${status === "added" ? "/dev/null" : `a/${oldPath}`}`, - `+++ ${status === "removed" ? "/dev/null" : `b/${value.filename}`}`, + ...(status === "renamed" + ? [ + `rename from ${quoteGitPatchPath(oldPath)}`, + `rename to ${quoteGitPatchPath(value.filename)}`, + ] + : []), + `--- ${status === "added" ? "/dev/null" : quoteGitPatchPath(`a/${oldPath}`)}`, + `+++ ${status === "removed" ? "/dev/null" : quoteGitPatchPath(`b/${value.filename}`)}`, ].join("\n"); sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`); } diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts index 5bb5264e7306..c9bc12743756 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts @@ -382,6 +382,57 @@ describe("decodeCommitsJson", () => { }); describe("decodeMergeRequestDiffsJson", () => { + it("quotes literal backslashes without interpreting them as escapes", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { + old_path: String.raw`src\notes.ts`, + new_path: String.raw`src\notes.ts`, + diff: "@@ -1 +1 @@\n-old\n+new", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`, + String.raw`--- "a/src\\notes.ts"`, + String.raw`+++ "b/src\\notes.ts"`, + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + }); + + it("preserves spaces and literal backslashes in both rename paths", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { + old_path: String.raw` old\name.ts `, + new_path: String.raw` new\name.ts `, + renamed_file: true, + diff: "", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`, + String.raw`rename from " old\\name.ts "`, + String.raw`rename to " new\\name.ts "`, + String.raw`--- "a/ old\\name.ts "`, + String.raw`+++ "b/ new\\name.ts "`, + ].join("\n"), + ); + }); + it("assembles a unified patch GitLab does not return", () => { const result = expectSuccess( decodeMergeRequestDiffsJson( diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts index 1e49e1a9581f..b9991920d0d3 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts @@ -19,6 +19,7 @@ import type { PullRequestState, } from "@t3tools/contracts"; import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; /** @@ -680,8 +681,8 @@ function diffHeaderPaths(raw: Schema.Schema.Type): { readonly to: string; } { return { - from: raw.new_file === true ? "/dev/null" : `a/${raw.old_path}`, - to: raw.deleted_file === true ? "/dev/null" : `b/${raw.new_path}`, + from: raw.new_file === true ? "/dev/null" : quoteGitPatchPath(`a/${raw.old_path}`), + to: raw.deleted_file === true ? "/dev/null" : quoteGitPatchPath(`b/${raw.new_path}`), }; } @@ -718,11 +719,14 @@ export function decodeMergeRequestDiffsJson( } const { from, to } = diffHeaderPaths(value); const header = [ - `diff --git a/${value.old_path} b/${value.new_path}`, + `diff --git ${quoteGitPatchPath(`a/${value.old_path}`)} ${quoteGitPatchPath(`b/${value.new_path}`)}`, ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []), ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []), ...(value.renamed_file === true - ? [`rename from ${value.old_path}`, `rename to ${value.new_path}`] + ? [ + `rename from ${quoteGitPatchPath(value.old_path)}`, + `rename to ${quoteGitPatchPath(value.new_path)}`, + ] : []), `--- ${from}`, `+++ ${to}`, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index cf8624aa22ae..f6a028989d41 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2740,13 +2740,15 @@ const makeWsRpcLayer = ( { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsFilesViewed]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsFilesViewed, pullRequests.filesViewed(input), { - "rpc.aggregate": "pull-requests", - }), + observeRpcEffect( + WS_METHODS.pullRequestsFilesViewed, + withPullRequestViewer(input, pullRequests.filesViewed(input)), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.pullRequestsSetFilesViewed]: (input) => observeRpcEffect( WS_METHODS.pullRequestsSetFilesViewed, - pullRequests.setFilesViewed(input), + withPullRequestViewer(input, pullRequests.setFilesViewed(input)), { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsRunAction]: (input) => diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d6cf14bae0ba..b535c4f45e97 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -228,6 +228,8 @@ function PullRequestCodeTab({ const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE); /** Set once the reader has asked for every file at once, until they pick a file apart again. */ const [foldOverride, setFoldOverride] = useState(null); + const effectiveFoldOverride = + foldOverride ?? (settings.diffFilesCollapsed ? "folded" : "expanded"); const diffLayout = settings.diffLayout; const updateClientSettings = useUpdateClientSettings(); const [wordWrap, setWordWrap] = useState(settings.wordWrap); @@ -561,11 +563,7 @@ function PullRequestCodeTab({ const items = useMemo[]>( () => annotatedFiles.map(({ fileKey, path, fileDiff, annotations, annotationsVersion }) => { - const collapsed = isFileDiffCollapsed( - fileKey, - foldOverride ?? (settings.diffFilesCollapsed ? "folded" : "expanded"), - toggledFiles, - ); + const collapsed = isFileDiffCollapsed(fileKey, effectiveFoldOverride, toggledFiles); // Ticking a file that is already folded changes no fold, so without this the box on // screen would keep saying the opposite of what the count says. const viewedMark = filesViewedEnabled @@ -583,10 +581,9 @@ function PullRequestCodeTab({ [ annotatedFiles, filesViewedEnabled, - foldOverride, + effectiveFoldOverride, isFileViewed, isFileViewedStale, - settings.diffFilesCollapsed, toggledFiles, ], ); @@ -658,10 +655,10 @@ function PullRequestCodeTab({ (fileKey: string, path: string, viewed: boolean) => { setViewed(path, viewed); setToggledFiles((current) => - toggleFileDiffFoldForViewed(fileKey, viewed, foldOverride, current), + toggleFileDiffFoldForViewed(fileKey, viewed, effectiveFoldOverride, current), ); }, - [foldOverride, setViewed], + [effectiveFoldOverride, setViewed], ); const requestTreeReveal = useCodeViewFileReveal(viewer, scopeKey); diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index 555c870d04ef..64ccb9cdad89 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -32,10 +32,12 @@ const reads = new Set([ WS_METHODS.pullRequestsActivity, WS_METHODS.pullRequestsThreadComments, WS_METHODS.pullRequestsDiffFileContents, + WS_METHODS.pullRequestsFilesViewed, WS_METHODS.pullRequestsReviewerCandidates, WS_METHODS.pullRequestsLabelCandidates, ]); const writes = new Set([ + WS_METHODS.pullRequestsSetFilesViewed, WS_METHODS.pullRequestsRunAction, WS_METHODS.pullRequestsUpdate, WS_METHODS.pullRequestsComment, @@ -182,7 +184,7 @@ export function createPullRequestRouter() { targets, ([target, reference]) => invalidateTarget(registry, origin.target.environmentId, target, [ - input.reference === undefined ? {} : { reference }, + { ...input, ...(input.reference === undefined ? {} : { reference }) }, ]), { concurrency: 4, discard: true }, ); @@ -229,8 +231,13 @@ export function createPullRequestRouter() { targets, ([target, refs]) => invalidateTarget(registry, origin.target.environmentId, target, [ - ...refs.map((reference) => ({ reference })), - {}, + ...refs.map((reference) => ({ + reference, + ...(tag === WS_METHODS.pullRequestsSetFilesViewed + ? { filesViewedOnly: true } + : {}), + })), + ...(tag === WS_METHODS.pullRequestsSetFilesViewed ? [] : [{}]), ]), { concurrency: 4, discard: true }, ); diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 5fcf5fdf681e..7bb86a049e18 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -235,6 +235,84 @@ for (const scenario of [ ); } +for (const provider of ["github", "gitlab", "bitbucket", "azure-devops"] as const) { + it.effect(`routes ${provider} viewed marks to their storage environment`, () => + Effect.scoped( + Effect.gen(function* () { + const reference = { + projectId: ProjectId.make("project-1"), + host: "github.com", + repository: "acme/web", + number: 7, + }; + const calls: { environment: string; operation: string; input: unknown }[] = []; + const clientFor = (environment: string) => + ({ + [WS_METHODS.pullRequestsRouting]: () => + Effect.succeed({ + host: reference.host, + provider, + accountId: "123", + viewer: "maria-rcks", + }), + [WS_METHODS.pullRequestsRoutingIdentity]: () => + Effect.succeed({ + host: reference.host, + provider, + accountId: "123", + viewer: "maria-rcks", + }), + [WS_METHODS.pullRequestsFilesViewed]: (input: unknown) => + Effect.sync(() => { + calls.push({ environment, operation: "read", input }); + return { files: [{ path: "a.ts", state: "viewed" }], truncated: false }; + }), + [WS_METHODS.pullRequestsSetFilesViewed]: (input: unknown) => + Effect.sync(() => { + calls.push({ environment, operation: "write", input }); + }), + [WS_METHODS.pullRequestsInvalidate]: (input: unknown) => + Effect.sync(() => { + calls.push({ environment, operation: "invalidate", input }); + }), + }) as unknown as WsRpcProtocolClient; + const { environmentRegistry, supervisor } = yield* makeTestRuntime( + clientFor("origin"), + clientFor("local"), + ); + const files = [{ path: "a.ts", viewed: false }]; + const route = createPullRequestRouter(); + yield* Effect.gen(function* () { + expect(yield* route(WS_METHODS.pullRequestsFilesViewed, reference)).toEqual({ + files: [{ path: "a.ts", state: "viewed" }], + truncated: false, + }); + yield* route(WS_METHODS.pullRequestsSetFilesViewed, { ...reference, files }); + }).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + const environment = provider === "github" ? "local" : "origin"; + const guard = provider === "github" ? { expectedAccountId: "123" } : {}; + expect(calls.filter((call) => call.operation !== "invalidate")).toEqual([ + { environment, operation: "read", input: { ...reference, allowStale: false, ...guard } }, + { environment, operation: "write", input: { ...reference, files, ...guard } }, + ]); + const invalidations = calls.filter((call) => call.operation === "invalidate"); + if (provider === "github") expect(invalidations.length).toBeGreaterThan(0); + else expect(invalidations).toEqual([]); + for (const call of invalidations) { + expect(call.input).toEqual({ + reference: expect.objectContaining(reference), + filesViewedOnly: true, + }); + } + }), + ), + ); +} + const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), label: "Test environment", diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index e9e39ce5b439..d8c000863ab1 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -273,6 +273,7 @@ export function createPullRequestEnvironmentAtoms( filesViewed: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:files-viewed", tag: WS_METHODS.pullRequestsFilesViewed, + execute: (input) => routedRequest(WS_METHODS.pullRequestsFilesViewed, input), staleTimeMs: 15_000, }), /** @@ -282,6 +283,7 @@ export function createPullRequestEnvironmentAtoms( setFilesViewed: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:set-files-viewed", tag: WS_METHODS.pullRequestsSetFilesViewed, + execute: (input) => routedRequest(WS_METHODS.pullRequestsSetFilesViewed, input), scheduler: commandScheduler, concurrency: { mode: "serial", diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 39868c05ec7f..978be0389b4b 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -795,6 +795,8 @@ export type PullRequestListStatsResult = typeof PullRequestListStatsResult.Type; */ export const PullRequestInvalidateInput = Schema.Struct({ reference: Schema.optional(PullRequestRef), + /** Refresh review progress across routed environments without discarding the patch. */ + filesViewedOnly: Schema.optional(Schema.Boolean), }); export type PullRequestInvalidateInput = typeof PullRequestInvalidateInput.Type; From f28664a4cb616c32a861839063f18f506e600629 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 16 Sep 2026 19:12:25 -0300 Subject: [PATCH 75/78] fix(prs): skip linked sync for viewed-only invalidations --- apps/server/src/ws.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f6a028989d41..88d3ff5f5470 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2824,7 +2824,7 @@ const makeWsRpcLayer = ( // A reader asking for fresh host state also wants the thread badges it feeds to // catch up, including a merged link the sweep would otherwise never revisit. Effect.andThen( - input.reference === undefined + input.reference === undefined || input.filesViewedOnly === true ? Effect.void : resolvePullRequestSyncKey(input.reference).pipe( Effect.flatMap((key) => From 7534968fd0a0d2439cc902e8f5d4677165918609 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 17 Sep 2026 14:56:33 -0300 Subject: [PATCH 76/78] fix(prs): integrate main and track Forgejo viewed files --- apps/desktop/src/preview/Manager.test.ts | 21 +- apps/desktop/src/preview/Manager.ts | 9 +- apps/mobile/app.config.ts | 2 +- apps/mobile/assets/icons/compose.xml | 8 + .../T3MarkdownTextSelectionModule.kt | 26 + .../MarkdownSelectionColorTest.kt | 67 + .../src/MarkdownTextPrimitive.tsx | 53 +- .../src/NativeMarkdownBlock.tsx | 20 +- .../src/NativeMarkdownSelectableText.tsx | 2 + .../src/SelectableMarkdownText.types.ts | 2 + .../src/T3MarkdownTextSelectionModule.ts | 5 + .../src/nativeMarkdownText.ts | 4 +- .../T3NativeControlsModule.kt | 6 + apps/mobile/package.json | 1 + apps/mobile/src/App.tsx | 10 +- .../src/components/AndroidAnchoredMenu.tsx | 141 +- .../src/components/AndroidScreenHeader.tsx | 96 +- apps/mobile/src/components/AppSymbol.tsx | 13 + apps/mobile/src/components/AppText.tsx | 18 +- apps/mobile/src/components/ComposerEditor.tsx | 5 +- .../src/components/ConfirmDialogHost.tsx | 41 +- apps/mobile/src/components/ControlPill.tsx | 50 +- apps/mobile/src/components/EmptyState.tsx | 10 +- apps/mobile/src/components/GlassBackdrop.tsx | 21 +- apps/mobile/src/components/GlassSurface.tsx | 26 +- .../src/components/MaterialButton.android.tsx | 94 + apps/mobile/src/components/MaterialButton.tsx | 24 + .../MaterialConfirmDialog.android.tsx | 96 + .../src/components/MaterialConfirmDialog.tsx | 17 + .../MaterialFloatingActionButton.android.tsx | 86 + .../MaterialFloatingActionButton.tsx | 46 + .../components/MaterialIconButton.android.tsx | 87 + .../src/components/MaterialIconButton.tsx | 23 + .../mobile/src/components/MaterialListRow.tsx | 57 + .../components/MaterialMenuPopup.android.tsx | 153 ++ .../src/components/MaterialMenuPopup.tsx | 22 + .../MaterialNewThreadButton.android.tsx | 21 + .../MaterialNewThreadButton.shared.tsx | 40 + .../components/MaterialNewThreadButton.tsx | 1 + .../MaterialRadioIndicator.android.tsx | 55 + .../src/components/MaterialRadioIndicator.tsx | 8 + .../src/components/MaterialScreenContent.tsx | 33 + .../MaterialScrollComposeButton.android.tsx | 75 + .../MaterialSegmentedButtons.android.tsx | 37 + .../MaterialSegmentedControl.android.tsx | 40 + .../components/MaterialSegmentedControl.tsx | 7 + .../src/components/MaterialSwitch.android.tsx | 49 + apps/mobile/src/components/MaterialSwitch.tsx | 1 + .../src/components/ScreenScrollView.tsx | 19 + .../src/components/SegmentedControl.tsx | 19 +- apps/mobile/src/components/ThemedSwitch.tsx | 68 +- .../components/useMaterialToolbarHeight.ts | 11 + .../archive/ArchivedThreadsScreen.tsx | 78 +- .../cloud/ConnectOnboardingRouteScreen.tsx | 3 +- .../connection/ConnectionEnvironmentRow.tsx | 108 +- .../connection/ConnectionSheetButton.tsx | 12 + .../connection/ConnectionsNewRouteScreen.tsx | 82 +- .../connection/ConnectionsRouteScreen.tsx | 3 +- .../SettingsDiagnosticsRouteScreen.tsx | 12 +- .../features/files/MaterialFilesHeader.tsx | 122 + .../src/features/files/SourceFileSurface.tsx | 6 + .../features/files/ThreadFilesRouteScreen.tsx | 144 +- .../files/thread-file-navigator-pane.tsx | 107 +- .../features/home/AndroidHomeFab.android.tsx | 44 + .../features/home/AndroidHomeFab.shared.tsx | 10 + .../src/features/home/AndroidHomeFab.tsx | 49 +- apps/mobile/src/features/home/HomeHeader.tsx | 139 +- .../src/features/home/HomeRouteScreen.tsx | 26 +- apps/mobile/src/features/home/HomeScreen.tsx | 72 +- .../features/home/MaterialFabScrollContext.ts | 10 + .../home/MaterialThreadListToolbar.tsx | 178 ++ .../features/home/material-fab-scroll.test.ts | 40 + .../src/features/home/material-fab-scroll.ts | 17 + .../layout/AdaptiveWorkspaceLayout.tsx | 15 +- .../features/layout/WorkspaceEmptyDetail.tsx | 81 +- .../layout/workspace-pane-divider.tsx | 3 +- .../layout/workspace-sidebar-toolbar.tsx | 18 + .../features/projects/AddProjectScreen.tsx | 99 +- .../src/features/review/ReviewSheet.tsx | 415 ++-- .../review/nativeReviewDiffAdapter.test.ts | 25 + .../review/nativeReviewDiffAdapter.ts | 20 +- .../SettingsAppearanceRouteScreen.tsx | 17 +- .../SettingsClientStorageRouteScreen.tsx | 18 +- .../SettingsEnvironmentsRouteScreen.tsx | 48 +- .../SettingsOpenSourceLicensesRouteScreen.tsx | 48 +- .../SettingsProjectGroupingRouteScreen.tsx | 18 +- .../features/settings/SettingsRouteScreen.tsx | 106 +- .../AppearancePreferencesProvider.tsx | 20 +- .../components/FontSizeSliderRow.android.tsx | 81 + .../components/FontSizeSliderRow.shared.tsx | 211 ++ .../components/FontSizeSliderRow.tsx | 211 +- .../sections/ThemeAppearanceSection.tsx | 19 +- .../AutoSettleDaysField.android.tsx | 50 + .../components/AutoSettleDaysField.ios.tsx | 94 + .../components/AutoSettleDaysField.tsx | 42 + .../settings/components/SettingsRow.tsx | 91 +- .../settings/components/SettingsScreen.tsx | 43 + .../settings/components/SettingsSection.tsx | 20 +- .../settings/components/SettingsSwitchRow.tsx | 28 +- .../terminal/ThreadTerminalRouteScreen.tsx | 342 +-- .../threads/CustomSnoozeSheet.android.tsx | 287 +++ .../threads/CustomSnoozeSheet.ios.tsx | 346 ++- .../threads/CustomSnoozeSheet.shared.tsx | 181 ++ .../features/threads/CustomSnoozeSheet.tsx | 183 +- .../threads/NewTaskContextPickerScreens.tsx | 173 +- .../features/threads/NewTaskDraftScreen.tsx | 41 +- .../features/threads/NewTaskRouteScreen.tsx | 245 +- .../src/features/threads/ThreadComposer.tsx | 20 +- .../features/threads/ThreadDetailScreen.tsx | 94 +- .../src/features/threads/ThreadFeed.tsx | 70 +- .../threads/ThreadNavigationSidebar.tsx | 272 ++- .../features/threads/ThreadRouteScreen.tsx | 199 +- .../features/threads/ThreadSettingsSheet.tsx | 388 ++- .../features/threads/customSnoozeDate.test.ts | 36 + .../src/features/threads/customSnoozeDate.ts | 16 + .../features/threads/git/GitBranchesSheet.tsx | 319 ++- .../features/threads/git/GitCommitSheet.tsx | 414 ++-- .../features/threads/git/GitConfirmSheet.tsx | 113 +- .../features/threads/git/GitOverviewSheet.tsx | 119 +- .../threads/git/gitSheetComponents.tsx | 77 +- .../features/threads/thread-list-items.tsx | 79 +- .../features/threads/thread-list-v2-items.tsx | 78 +- .../features/threads/thread-search-match.tsx | 9 +- .../thread-settings-sheet-state.test.ts | 43 + .../threads/thread-settings-sheet-state.ts | 34 + .../src/features/threads/thread-work-log.tsx | 2 +- .../threads/use-composer-command-menu.ts | 2 +- .../features/threads/use-worktree-setup.ts | 50 + .../features/threads/worktree-setup-card.tsx | 391 +++ .../threads/worktree-setup-sheet.android.tsx | 47 + .../features/threads/worktree-setup-sheet.tsx | 66 + .../src/features/usage/UsageLimitsPooled.tsx | 16 +- .../src/features/usage/UsageRouteScreen.tsx | 26 +- .../voice-input/useVoiceInputController.ts | 17 +- apps/mobile/src/lib/appBlurTarget.ts | 8 - apps/mobile/src/lib/glassBlurTarget.ts | 6 - apps/mobile/src/lib/materialYouTheme.test.ts | 18 +- .../src/lib/mobileThemeVariables.test.ts | 43 +- apps/mobile/src/lib/mobileThemeVariables.ts | 18 +- .../mobile/src/lib/nativeMarkdownText.test.ts | 43 +- apps/mobile/src/lib/storage.test.ts | 24 +- apps/mobile/src/lib/threadActivity.test.ts | 297 +++ apps/mobile/src/lib/threadActivity.ts | 251 +- .../native/SelectableMarkdownText.android.tsx | 18 +- .../src/native/T3ComposerEditor.native.tsx | 4 +- .../src/persistence/mobile-preferences.ts | 25 +- apps/mobile/src/state/preferences.test.ts | 40 + apps/mobile/src/state/preferences.ts | 13 +- .../OrchestrationEngineHarness.integration.ts | 7 + .../checkpointing/CheckpointDiffQuery.test.ts | 5 + apps/server/src/cli/update.ts | 32 +- apps/server/src/cli/updateProgress.test.ts | 101 + apps/server/src/cli/updateProgress.ts | 72 + apps/server/src/cloud/pinnedRuntime.test.ts | 121 + apps/server/src/cloud/pinnedRuntime.ts | 45 +- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 3 + .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/OrchestrationReactor.test.ts | 11 + .../Layers/OrchestrationReactor.ts | 3 + .../Layers/ProjectionPipeline.test.ts | 124 + .../Layers/ProjectionPipeline.ts | 12 + .../Layers/ProjectionSnapshotQuery.test.ts | 34 + .../Layers/ProjectionSnapshotQuery.ts | 31 + .../Layers/ProviderCommandReactor.ts | 16 +- .../Layers/ProviderRuntimeIngestion.test.ts | 175 +- .../Layers/ProviderRuntimeIngestion.ts | 157 +- .../PullRequestSyncReactor.test.ts | 164 +- .../orchestration/PullRequestSyncReactor.ts | 94 +- .../Services/ProjectionSnapshotQuery.ts | 13 + .../ThreadSettlementReactor.test.ts | 614 ++++- .../orchestration/ThreadSettlementReactor.ts | 49 +- apps/server/src/orchestration/decider.ts | 20 +- .../decider.turnDiffComplete.test.ts | 127 + apps/server/src/processRunner.ts | 7 +- .../src/project/AgentSessionScanner.test.ts | 1 + .../project/ProjectSetupScriptRunner.test.ts | 1 + .../Drivers/ClaudeSkillDispatch.test.ts | 33 +- .../provider/Drivers/ClaudeSkillDispatch.ts | 7 +- .../src/provider/Drivers/CursorSkills.ts | 4 +- .../Layers/CodexSessionRuntime.test.ts | 17 + .../provider/Layers/CodexSessionRuntime.ts | 6 +- .../provider/Layers/CursorProvider.test.ts | 16 + .../provider/Layers/ProviderService.test.ts | 1 + .../Layers/ProviderSessionReaper.test.ts | 1 + apps/server/src/provider/model-manifest.json | 1 - .../pullRequest/ForgejoPullRequestProvider.ts | 13 + .../src/pullRequest/GitHubPullRequestCli.ts | 3 + .../GitHubPullRequestProvider.test.ts | 5 +- .../pullRequest/GitHubPullRequestProvider.ts | 36 +- .../pullRequest/PullRequestService.test.ts | 291 ++- .../src/pullRequest/PullRequestService.ts | 32 +- .../pullRequest/gitHubPullRequestJson.test.ts | 16 +- .../src/pullRequest/gitHubPullRequestJson.ts | 34 +- apps/server/src/server.test.ts | 135 +- apps/server/src/server.ts | 2 + apps/server/src/serverRuntimeStartup.test.ts | 4 + apps/server/src/storageCleanup.ts | 482 ++++ apps/server/src/terminal/Manager.ts | 20 +- apps/server/src/vcs/GitVcsDriver.test.ts | 274 +++ apps/server/src/vcs/GitVcsDriver.ts | 131 +- apps/server/src/vcs/VcsProcess.test.ts | 18 + apps/server/src/vcs/VcsProcess.ts | 2 + apps/server/src/workspace/workspaceLease.ts | 23 + apps/server/src/ws.ts | 20 +- apps/web/package.json | 8 +- apps/web/src/clientPersistenceStorage.test.ts | 4 +- apps/web/src/components/AppSidebarLayout.tsx | 15 +- apps/web/src/components/BranchToolbar.tsx | 70 +- .../BranchToolbarBranchSelector.tsx | 6 +- .../BranchToolbarEnvModeSelector.tsx | 108 +- .../BranchToolbarEnvironmentSelector.tsx | 92 +- apps/web/src/components/ChatView.logic.ts | 59 +- apps/web/src/components/ChatView.tsx | 458 +++- .../components/CommandPalette.logic.test.ts | 65 + .../src/components/CommandPalette.logic.ts | 16 +- apps/web/src/components/CommandPalette.tsx | 225 +- .../src/components/ComposerCitationNode.tsx | 223 -- .../ComposerContextReferenceNode.test.ts | 92 - .../ComposerContextReferenceNode.tsx | 133 - ...omposerPromptEditor.serialization.test.tsx | 183 -- .../components/ComposerPromptEditor.test.ts | 864 ------- .../src/components/ComposerPromptEditor.tsx | 2144 +---------------- .../components/ComposerPromptEditorTiptap.tsx | 1379 +++++++++++ apps/web/src/components/DiffPanel.tsx | 9 +- apps/web/src/components/chat/ChatComposer.tsx | 131 +- .../src/components/chat/ComposerBanner.tsx | 9 +- .../ComposerPendingApprovalActions.test.tsx | 47 +- .../chat/ComposerPendingApprovalActions.tsx | 71 +- .../ComposerPendingApprovalPanel.test.tsx | 18 +- .../chat/ComposerPendingApprovalPanel.tsx | 31 +- .../src/components/chat/ComposerStashMenu.tsx | 2 +- .../components/chat/ComposerTasksBadge.tsx | 23 +- .../chat/MessagesTimeline.logic.test.ts | 292 ++- .../components/chat/MessagesTimeline.logic.ts | 124 +- .../src/components/chat/MessagesTimeline.tsx | 438 +++- apps/web/src/components/chat/ModelListRow.tsx | 6 +- .../components/chat/ModelPickerContent.tsx | 66 +- .../components/chat/ProviderModelPicker.tsx | 72 +- .../src/components/chat/SkillInlineText.tsx | 4 +- .../chat/timelineScrollAnchoring.test.tsx | 18 + .../chat/timelineScrollAnchoring.ts | 35 + .../components/composerInlineTokenPaste.ts | 140 +- .../src/components/files/FileBrowserPanel.tsx | 17 +- .../src/components/files/FilePreviewPanel.tsx | 55 +- .../files/projectFilesQueryState.test.tsx | 59 + .../files/projectFilesQueryState.ts | 50 +- .../pullRequest/PullRequestCommentBody.tsx | 61 + .../pullRequest/PullRequestDetailPanel.tsx | 11 +- .../pullRequest/PullRequestReactions.tsx | 15 +- .../PullRequestReviewAnnotation.tsx | 20 +- .../PullRequestSummaryTab.test.tsx | 75 +- .../pullRequest/PullRequestSummaryTab.tsx | 565 +++-- .../pullRequest/PullRequestTimelineTab.tsx | 77 +- .../pullRequestDetail.logic.test.ts | 111 +- .../pullRequest/pullRequestDetail.logic.ts | 45 +- .../pullRequestPresentation.test.tsx | 28 + .../pullRequest/pullRequestPresentation.tsx | 13 +- .../settings/KeybindingsSettings.tsx | 34 +- .../settings/SettingsFontPreviews.tsx | 2 +- .../components/settings/SettingsPanels.tsx | 62 +- .../settings/SettingsSidebarNav.tsx | 2 + .../components/settings/StorageSettings.tsx | 288 +++ .../settings/ThemePreviewCircles.tsx | 7 +- .../settings/scopedSettings.test.ts | 112 + .../src/components/settings/scopedSettings.ts | 31 +- .../settings/settingsSearch.test.ts | 26 + .../src/components/settings/settingsSearch.ts | 68 +- apps/web/src/components/ui/menu.tsx | 9 +- apps/web/src/components/ui/toast.tsx | 54 +- apps/web/src/composer-editor-mentions.test.ts | 8 +- apps/web/src/composer-editor-mentions.ts | 3 +- .../src/composer-list-continuation.test.ts | 68 + apps/web/src/composer-list-continuation.ts | 136 ++ apps/web/src/composer-logic.test.ts | 44 +- apps/web/src/composer-logic.ts | 9 +- apps/web/src/composer-rich-text-doc.test.ts | 354 +++ apps/web/src/composer-rich-text-doc.ts | 644 +++++ apps/web/src/composer-rich-text.test.ts | 67 + apps/web/src/composer-rich-text.ts | 131 + apps/web/src/diffPanelStore.test.ts | 41 +- apps/web/src/diffPanelStore.ts | 9 +- .../src/hooks/useOpenPanelPullRequestUrl.ts | 39 +- apps/web/src/hooks/useThreadActions.ts | 11 +- apps/web/src/index.css | 102 + apps/web/src/keybindings.test.ts | 16 + apps/web/src/keybindings.ts | 17 + apps/web/src/providerSkillSearch.test.ts | 8 + apps/web/src/providerSkillSearch.ts | 2 +- apps/web/src/rightPanelStore.test.ts | 30 + apps/web/src/rightPanelStore.ts | 6 +- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/settings.storage.tsx | 4 + apps/web/src/themePalette.ts | 4 +- apps/web/src/workspaceTitlebar.ts | 2 +- docs/internals/composer-editors.md | 27 + docs/user/appearance.md | 9 +- docs/user/composer.md | 3 +- docs/user/project-settings.md | 26 + docs/user/source-control.md | 2 +- docs/user/thread-sidebar.md | 5 + packages/client-runtime/package.json | 4 + .../src/errors/orchestration.test.ts | 29 +- .../src/errors/orchestration.ts | 6 + .../src/state/pullRequests.test.ts | 103 + .../src/work-log/presentation.ts | 2 +- packages/client-runtime/src/worktreeSetup.ts | 64 + packages/contracts/src/environment.test.ts | 10 + packages/contracts/src/environment.ts | 4 + packages/contracts/src/keybindings.ts | 2 + packages/contracts/src/orchestration.test.ts | 14 + packages/contracts/src/orchestration.ts | 3 +- packages/contracts/src/pullRequest.ts | 2 + packages/contracts/src/settings.test.ts | 61 +- packages/contracts/src/settings.ts | 71 +- .../shared/src/composerInlineTokens.test.ts | 31 +- packages/shared/src/composerInlineTokens.ts | 2 +- packages/shared/src/composerTrigger.test.ts | 17 +- packages/shared/src/composerTrigger.ts | 5 +- packages/shared/src/keybindings.ts | 2 + packages/shared/src/projectSettings.test.ts | 58 + packages/shared/src/projectSettings.ts | 20 + packages/shared/src/serverSettings.test.ts | 17 + packages/shared/src/serverSettings.ts | 21 + packages/shared/src/usageMerge.test.ts | 20 + packages/shared/src/usageMerge.ts | 12 +- pnpm-lock.yaml | 821 ++++--- scripts/install.ps1 | 120 +- scripts/install.sh | 103 +- scripts/install.test.ts | 115 + vite.config.ts | 2 + 331 files changed, 19218 insertions(+), 8168 deletions(-) create mode 100644 apps/mobile/assets/icons/compose.xml create mode 100644 apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt create mode 100644 apps/mobile/src/components/MaterialButton.android.tsx create mode 100644 apps/mobile/src/components/MaterialButton.tsx create mode 100644 apps/mobile/src/components/MaterialConfirmDialog.android.tsx create mode 100644 apps/mobile/src/components/MaterialConfirmDialog.tsx create mode 100644 apps/mobile/src/components/MaterialFloatingActionButton.android.tsx create mode 100644 apps/mobile/src/components/MaterialFloatingActionButton.tsx create mode 100644 apps/mobile/src/components/MaterialIconButton.android.tsx create mode 100644 apps/mobile/src/components/MaterialIconButton.tsx create mode 100644 apps/mobile/src/components/MaterialListRow.tsx create mode 100644 apps/mobile/src/components/MaterialMenuPopup.android.tsx create mode 100644 apps/mobile/src/components/MaterialMenuPopup.tsx create mode 100644 apps/mobile/src/components/MaterialNewThreadButton.android.tsx create mode 100644 apps/mobile/src/components/MaterialNewThreadButton.shared.tsx create mode 100644 apps/mobile/src/components/MaterialNewThreadButton.tsx create mode 100644 apps/mobile/src/components/MaterialRadioIndicator.android.tsx create mode 100644 apps/mobile/src/components/MaterialRadioIndicator.tsx create mode 100644 apps/mobile/src/components/MaterialScreenContent.tsx create mode 100644 apps/mobile/src/components/MaterialScrollComposeButton.android.tsx create mode 100644 apps/mobile/src/components/MaterialSegmentedButtons.android.tsx create mode 100644 apps/mobile/src/components/MaterialSegmentedControl.android.tsx create mode 100644 apps/mobile/src/components/MaterialSegmentedControl.tsx create mode 100644 apps/mobile/src/components/MaterialSwitch.android.tsx create mode 100644 apps/mobile/src/components/MaterialSwitch.tsx create mode 100644 apps/mobile/src/components/ScreenScrollView.tsx create mode 100644 apps/mobile/src/components/useMaterialToolbarHeight.ts create mode 100644 apps/mobile/src/features/files/MaterialFilesHeader.tsx create mode 100644 apps/mobile/src/features/home/AndroidHomeFab.android.tsx create mode 100644 apps/mobile/src/features/home/AndroidHomeFab.shared.tsx create mode 100644 apps/mobile/src/features/home/MaterialFabScrollContext.ts create mode 100644 apps/mobile/src/features/home/MaterialThreadListToolbar.tsx create mode 100644 apps/mobile/src/features/home/material-fab-scroll.test.ts create mode 100644 apps/mobile/src/features/home/material-fab-scroll.ts create mode 100644 apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.android.tsx create mode 100644 apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.shared.tsx create mode 100644 apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx create mode 100644 apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx create mode 100644 apps/mobile/src/features/settings/components/AutoSettleDaysField.tsx create mode 100644 apps/mobile/src/features/settings/components/SettingsScreen.tsx create mode 100644 apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx create mode 100644 apps/mobile/src/features/threads/CustomSnoozeSheet.shared.tsx create mode 100644 apps/mobile/src/features/threads/customSnoozeDate.test.ts create mode 100644 apps/mobile/src/features/threads/customSnoozeDate.ts create mode 100644 apps/mobile/src/features/threads/use-worktree-setup.ts create mode 100644 apps/mobile/src/features/threads/worktree-setup-card.tsx create mode 100644 apps/mobile/src/features/threads/worktree-setup-sheet.android.tsx create mode 100644 apps/mobile/src/features/threads/worktree-setup-sheet.tsx delete mode 100644 apps/mobile/src/lib/appBlurTarget.ts delete mode 100644 apps/mobile/src/lib/glassBlurTarget.ts create mode 100644 apps/server/src/cli/updateProgress.test.ts create mode 100644 apps/server/src/cli/updateProgress.ts create mode 100644 apps/server/src/orchestration/decider.turnDiffComplete.test.ts create mode 100644 apps/server/src/storageCleanup.ts create mode 100644 apps/server/src/workspace/workspaceLease.ts delete mode 100644 apps/web/src/components/ComposerCitationNode.tsx delete mode 100644 apps/web/src/components/ComposerContextReferenceNode.test.ts delete mode 100644 apps/web/src/components/ComposerContextReferenceNode.tsx delete mode 100644 apps/web/src/components/ComposerPromptEditor.serialization.test.tsx delete mode 100644 apps/web/src/components/ComposerPromptEditor.test.ts create mode 100644 apps/web/src/components/ComposerPromptEditorTiptap.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestCommentBody.tsx create mode 100644 apps/web/src/components/pullRequest/pullRequestPresentation.test.tsx create mode 100644 apps/web/src/components/settings/StorageSettings.tsx create mode 100644 apps/web/src/composer-list-continuation.test.ts create mode 100644 apps/web/src/composer-list-continuation.ts create mode 100644 apps/web/src/composer-rich-text-doc.test.ts create mode 100644 apps/web/src/composer-rich-text-doc.ts create mode 100644 apps/web/src/composer-rich-text.test.ts create mode 100644 apps/web/src/composer-rich-text.ts create mode 100644 apps/web/src/routes/settings.storage.tsx create mode 100644 docs/internals/composer-editors.md create mode 100644 packages/client-runtime/src/worktreeSetup.ts create mode 100644 scripts/install.test.ts diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 66c91981b667..7b76a1b8003a 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -3506,7 +3506,10 @@ describe("PreviewManager", () => { listeners.set(event, listener); }), once: vi.fn((event: string, listener: (...args: unknown[]) => void) => { - listeners.set(event, listener); + listeners.set(event, (...args) => { + listeners.delete(event); + listener(...args); + }); }), off: vi.fn(), ipc: { on: vi.fn(), off: vi.fn(), removeListener: vi.fn() }, @@ -3528,11 +3531,23 @@ describe("PreviewManager", () => { const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); yield* Effect.yieldNow; - listeners.get("did-start-navigation")?.({}, "about:blank", false, false); + listeners.get("did-start-navigation")?.({ + url: "about:blank", + isSameDocument: false, + isMainFrame: false, + frame: null, + }); yield* Effect.yieldNow; expect(pick.pollUnsafe()).toBeUndefined(); - listeners.get("did-start-navigation")?.({}, "https://example.com/next", false, true); + listeners.get("did-start-navigation")?.({ + url: "https://example.com/next", + isSameDocument: false, + isMainFrame: true, + frame: null, + }); + yield* Effect.yieldNow; + expect(pick.pollUnsafe()).toBeDefined(); expect(yield* Fiber.join(pick)).toBeNull(); }), ), diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 7cf73af91e92..a2e34fe54736 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -2566,12 +2566,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; const onDestroyed = () => settle(null); const onNavigated = ( - _event: Electron.Event, - _url: string, - _isInPlace: boolean, - isMainFrame: boolean, + event: Electron.Event, ) => { - if (isMainFrame) settle(null); + if (event.isMainFrame) settle(null); }; const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { // Two picks on one tab can overlap. Swap this session in and cancel @@ -2592,7 +2589,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); wc.once("destroyed", onDestroyed); - wc.once("did-start-navigation", onNavigated); + wc.on("did-start-navigation", onNavigated); if (!wc.isFocused()) wc.focus(); wc.send(START_PICK_CHANNEL, annotationTheme); }); diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ddc022508d05..097f8b92a7bd 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -214,7 +214,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.2.0", + version: "1.2.1", runtimeVersion: { // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay diff --git a/apps/mobile/assets/icons/compose.xml b/apps/mobile/assets/icons/compose.xml new file mode 100644 index 000000000000..1a958a9abc60 --- /dev/null +++ b/apps/mobile/assets/icons/compose.xml @@ -0,0 +1,8 @@ + + + + diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt index 26ceb2023235..bca3c6430304 100644 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt +++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt @@ -8,6 +8,7 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Typeface +import android.os.Build import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned @@ -164,6 +165,17 @@ private class SanitizingSelectionActionModeCallback( } } +internal fun applySelectionHandleColor(textView: TextView, color: Int) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return + textView.textSelectHandle?.mutate()?.apply { setTint(color) }?.let(textView::setTextSelectHandle) + textView.textSelectHandleLeft?.mutate()?.apply { + setTint(color) + }?.let(textView::setTextSelectHandleLeft) + textView.textSelectHandleRight?.mutate()?.apply { + setTint(color) + }?.let(textView::setTextSelectHandleRight) +} + class T3MarkdownTextSelectionModule : Module() { private val chipImages = LruCache>(128) @@ -183,9 +195,23 @@ class T3MarkdownTextSelectionModule : Module() { } }.fontMetricsInt + private fun setSelectionHandleColor(reactTag: Int, color: Int) { + val reactContext = appContext.reactContext as? ReactContext ?: return + reactContext.runOnUiQueueThread { + val textView = runCatching { + UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag) + }.getOrNull() as? TextView ?: return@runOnUiQueueThread + applySelectionHandleColor(textView, color) + } + } + override fun definition() = ModuleDefinition { Name("T3MarkdownTextSelection") + Function("setSelectionHandleColor") { reactTag: Int, color: Int -> + setSelectionHandleColor(reactTag, color) + } + Function("renderContextChip") { payloadJson: String -> val resources = appContext.reactContext?.resources ?: return@Function null val metrics = resources.displayMetrics diff --git a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt new file mode 100644 index 000000000000..81703090a3f0 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt @@ -0,0 +1,67 @@ +package expo.modules.t3markdowntext + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.widget.TextView +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class MarkdownSelectionColorTest { + private fun textView() = TextView(RuntimeEnvironment.getApplication()).apply { + setTextSelectHandle(ColorDrawable(Color.WHITE)) + setTextSelectHandleLeft(ColorDrawable(Color.WHITE)) + setTextSelectHandleRight(ColorDrawable(Color.WHITE)) + } + + private fun renderedColor(drawable: Drawable?): Int { + requireNotNull(drawable) + val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888) + drawable.setBounds(0, 0, 4, 4) + drawable.draw(Canvas(bitmap)) + val color = bitmap.getPixel(2, 2) + bitmap.recycle() + return color + } + + @Test + fun retintsAllHandlesWhenTheThemeChangesWithoutChangingTheHighlight() { + val text = textView() + val highlight = 0x52FF0088 + text.highlightColor = highlight + + for (color in listOf(Color.MAGENTA, Color.GREEN, Color.MAGENTA)) { + applySelectionHandleColor(text, color) + assertEquals(color, renderedColor(text.textSelectHandle)) + assertEquals(color, renderedColor(text.textSelectHandleLeft)) + assertEquals(color, renderedColor(text.textSelectHandleRight)) + assertEquals(highlight, text.highlightColor) + } + } + + @Test + fun doesNotTintOtherTextViewsSharingDrawableState() { + val original = ColorDrawable(Color.WHITE) + val first = textView().apply { + setTextSelectHandleLeft(original.constantState!!.newDrawable()) + } + val second = textView().apply { + setTextSelectHandleLeft(original.constantState!!.newDrawable()) + } + + applySelectionHandleColor(first, Color.MAGENTA) + + assertEquals(Color.MAGENTA, renderedColor(first.textSelectHandleLeft)) + assertEquals(Color.WHITE, renderedColor(second.textSelectHandleLeft)) + } +} diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx index c1e2df490e8f..1e9394bd6eca 100644 --- a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx @@ -1,5 +1,15 @@ import React, { type Ref } from "react"; -import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native"; +import { + findNodeHandle, + Platform, + processColor, + StyleSheet, + Text as RNText, + type ColorValue, + type TextProps, + type ViewStyle, +} from "react-native"; +import { setMarkdownSelectionHandleColor } from "./T3MarkdownTextSelectionModule"; import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; import { flattenStyles } from "./util"; @@ -34,6 +44,7 @@ export type ContextMenuActionEvent = { */ export type MarkdownTextPrimitiveProps = Omit & { nativeTextRef?: Ref; + selectionHandleColor?: ColorValue; uiTextView?: boolean; contextMenuConfig?: string; contextClipboardConfig?: string; @@ -116,7 +127,45 @@ function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPri return ; } -export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) { +function AndroidMarkdownText({ + nativeTextRef, + selectionHandleColor, + onLayout, + contextClipboardConfig: _contextClipboardConfig, + ...props +}: MarkdownTextPrimitiveProps) { + const textRef = React.useRef(null); + React.useImperativeHandle(nativeTextRef, () => textRef.current, []); + const color = processColor(selectionHandleColor); + const applyHandleColor = React.useCallback(() => { + if (!textRef.current || typeof color !== "number") return; + const reactTag = findNodeHandle(textRef.current); + if (reactTag !== null) setMarkdownSelectionHandleColor(reactTag, color); + }, [color]); + + // RN's selectionColor only sets the highlight. Retint mounted handles when + // the theme changes, and after layout when the native view first exists. + React.useEffect(applyHandleColor, [applyHandleColor]); + + return ( + { + applyHandleColor(); + onLayout?.(event); + }} + /> + ); +} + +export function MarkdownTextPrimitive({ + selectionHandleColor, + ...props +}: MarkdownTextPrimitiveProps) { + if (Platform.OS === "android" && selectionHandleColor !== undefined) { + return ; + } if (Platform.OS !== "ios") { const { nativeTextRef, contextClipboardConfig: _contextClipboardConfig, ...textProps } = props; return ; diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx index a4485ede4705..b57a182dbc56 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx @@ -133,7 +133,13 @@ function HighlightedCodeText(props: { } } return ( - + {props.highlighted ? lines : props.content} ); @@ -174,8 +180,10 @@ function NativeCodeBlock(props: { justifyContent: "space-between", }} > - {languageLabel} - + {props.node.alt ? ( - {props.node.alt} - + ) : null} ); diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx index 50a381bae288..1d9c2b93ede8 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx @@ -338,6 +338,8 @@ export function NativeMarkdownSelectableText(props: { } uiTextView selectable + selectionColor={props.textStyle.selectionColor} + selectionHandleColor={props.textStyle.selectionHandleColor} style={{ flexShrink: 1, minWidth: 0, 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 d67dcc5950de..adfa34365af6 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -1,4 +1,6 @@ export interface NativeMarkdownTextStyle { + readonly selectionColor?: string; + readonly selectionHandleColor?: string; readonly color: string; readonly strongColor: string; readonly mutedColor: string; diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts index 9f1d676179a8..56d8f6d4e0f8 100644 --- a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts @@ -1,6 +1,7 @@ import { requireOptionalNativeModule } from "expo"; interface T3MarkdownTextSelectionNativeModule { + readonly setSelectionHandleColor?: (reactTag: number, color: number) => void; readonly installCopySanitizer: (reactTag: number, contextClipboardConfig: string) => void; readonly renderContextChip?: (payloadJson: string) => { readonly uri: string; @@ -20,6 +21,10 @@ export function installMarkdownCopySanitizer(reactTag: number, contextClipboardC nativeModule?.installCopySanitizer(reactTag, contextClipboardConfig); } +export function setMarkdownSelectionHandleColor(reactTag: number, color: number): void { + nativeModule?.setSelectionHandleColor?.(reactTag, color); +} + export function renderAndroidContextChip(payloadJson: string) { return nativeModule?.renderContextChip?.(payloadJson) ?? null; } diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index ec8cf74fee2b..04682874db0c 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -319,7 +319,7 @@ function appendRun( } const SKILL_TOKEN_REGEX = - /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g; + /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; function formatSkillLabel(skill: SelectableMarkdownSkill): string { const displayName = skill.displayName?.trim(); @@ -359,7 +359,7 @@ function decorateSkillRuns( continue; } const start = (match.index ?? 0) + prefix.length; - const end = start + name.length + 1; + const end = (match.index ?? 0) + match[0].length; if (start > cursor) { decorated.push({ ...run, text: run.text.slice(cursor, start) }); } diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt index baea4a9590e7..7a5e11319288 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt @@ -1,6 +1,7 @@ package expo.modules.t3nativecontrols import android.content.Intent +import android.text.format.DateFormat import androidx.core.content.FileProvider import expo.modules.kotlin.Promise import expo.modules.kotlin.modules.Module @@ -15,6 +16,11 @@ class T3NativeControlsModule : Module() { override fun definition() = ModuleDefinition { Name("T3NativeControls") + Function("is24HourFormat") { + val context = appContext.reactContext ?: error("The app is not active.") + DateFormat.is24HourFormat(context) + } + AsyncFunction("openFile") { uri: String, mimeType: String, promise: Promise -> check(filePreviewPromise == null) { "A document viewer is already open." } val activity = appContext.currentActivity ?: error("The app is not active.") diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b1d736621407..031a23b666d1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -93,6 +93,7 @@ "expo-image": "~57.0.3", "expo-image-manipulator": "~57.0.17", "expo-image-picker": "~57.0.14", + "expo-keep-awake": "~57.0.2", "expo-linking": "~57.0.8", "expo-network": "~57.0.1", "expo-notifications": "~57.0.15", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 7b2c0318f9fb..34a742f8f11b 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,8 +1,7 @@ -import { BlurTargetView } from "expo-blur"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; import { useEffect } from "react"; -import { StatusBar } from "react-native"; +import { StatusBar, View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; @@ -21,10 +20,8 @@ import { import { RootStack } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; import { OverlayPortalHost } from "./components/OverlayPortal"; -import { appBlurTargetRef } from "./lib/appBlurTarget"; import { shouldHandleAppLink } from "./lib/appLinking"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; - import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; import "../global.css"; @@ -88,14 +85,13 @@ function AppContent() { this, React Navigation defaults to its light theme and every native header (glass buttons, title, materials) is forced light even when the system is in dark mode. */} - {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} - + - + {/* Anchored-menu overlays render here — in-window, so the keyboard stays up while a dropdown is open. */} diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index 79ec95d0a014..dfa0dea8d05a 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -6,12 +6,8 @@ import { BackHandler, Pressable, ScrollView, View } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; -import { appBlurTargetRef } from "../lib/appBlurTarget"; -import { cn } from "../lib/cn"; -import { type AppSymbolName, SymbolView } from "./AppSymbol"; -import { AppText as Text } from "./AppText"; import { OverlayPortal } from "./OverlayPortal"; -import { GlassBackdrop } from "./GlassBackdrop"; +import { MaterialMenuPopup } from "./MaterialMenuPopup"; const MENU_WIDTH = 250; const SCREEN_MARGIN = 12; @@ -27,6 +23,7 @@ type AnchorSnapshot = { readonly y: number; readonly width: number; readonly height: number; + readonly keyboardWasVisible: boolean; }; type OverlayFrame = { @@ -53,13 +50,9 @@ export type AndroidAnchoredMenuProps = { }; /** - * Token-styled anchored dropdown for Android, drop-in for the subset of the - * MenuView contract the app uses (actions with state/subtitle/image/ - * attributes, one level of subactions). The native AppCompat PopupMenu caps - * out on theming — stock animation, item metrics, and submenu chrome — so - * ControlPillMenu renders this instead on Android while iOS keeps the native - * UIMenu. Styling follows the themed native popup (12dp radius, plain rows, - * trailing check glyph); submenus drill in under a muted parent-title header. + * Adapts the app's MenuView actions to Material dropdowns on Android. Editor + * menus render native Material rows in-window to retain keyboard focus; other + * menus use the native popup for placement, animation and dismissal. */ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const [anchor, setAnchor] = useState(null); @@ -89,9 +82,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const open = useCallback(() => { anchorRef.current?.measureInWindow((x, y, width, height) => { - setAnchor({ x, y, width, height }); + setAnchor({ x, y, width, height, keyboardWasVisible: keyboardVisible }); }); - }, []); + }, [keyboardVisible]); const measureOverlay = useCallback(() => { overlayRef.current?.measureInWindow((x, y, width, height) => { @@ -100,18 +93,11 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { }); }, []); - // The dropdown renders in-window (no Modal takes focus), so the hardware - // back gesture needs explicit handling while it is open. Back steps out of - // a drilled-in submenu one level at a time (mirroring the tappable parent - // header) before closing the menu. Under predictive back - // (enableOnBackInvokedCallback) this stays correct: back reaches JS - // through always-registered OnBackPressedDispatcher callbacks (react-native - // core on Android 16+, withAndroidPredictiveBackCompat on 13-15), which - // also keeps the system from playing a "leave app" preview while the menu - // merely closes. + // The native popup owns back dismissal. In-window menus need a handler; + // back returns to the parent submenu before closing the overlay. const submenuDepth = path.length; useEffect(() => { - if (anchor === null) { + if (anchor === null || !anchor.keyboardWasVisible) { return; } const subscription = BackHandler.addEventListener("hardwareBackPress", () => { @@ -212,10 +198,20 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { onLayout={measureOverlay} > - {!placeable || local === null ? null : ( + {!placeable || local === null ? null : !anchor.keyboardWasVisible ? ( + setPath((current) => current.slice(0, -1))} + onClose={close} + /> + ) : ( - + {/* Compose DropdownMenu takes popup focus in the pinned Expo UI version. + Keep editor menus in-window so opening one preserves the keyboard. */} + {/* keyboardShouldPersistTaps: the menu often opens over an active editor; the first item tap must act, not just dismiss the keyboard. */} - {parent !== null ? ( - // Muted parent title as the submenu header; tapping it - // steps back, but it reads as a label, not a button. - setPath((current) => current.slice(0, -1))} - > - - {parent.title} - - - ) : props.title ? ( - <> - - - {props.title} - - - - - ) : null} - {levelActions.map((action, index) => { - const destructive = action.attributes?.destructive ?? false; - const disabled = action.attributes?.disabled ?? false; - const hasSubmenu = (action.subactions?.length ?? 0) > 0; - return ( - onPressItem(action)} - > - - - {action.title} - - {action.subtitle ? ( - - {action.subtitle} - - ) : null} - - {hasSubmenu ? ( - - ) : action.state === "on" ? ( - - ) : action.image ? ( - - ) : null} - - ); - })} + setPath((current) => current.slice(0, -1))} + onClose={close} + /> )} diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index f397c18a0a8f..2695eee5f01f 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -1,16 +1,21 @@ -import type { ReactNode } from "react"; -import { Pressable, View } from "react-native"; +import { useState, type ReactNode } from "react"; +import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { SymbolView, type AppSymbolName } from "./AppSymbol"; +import type { AppSymbolName } from "./AppSymbol"; import { AppText as Text } from "./AppText"; import { cn } from "../lib/cn"; +import { MaterialIconButton } from "./MaterialIconButton"; +import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; +import { useMaterialToolbarHeight } from "./useMaterialToolbarHeight"; export interface AndroidHeaderAction { readonly accessibilityLabel: string; readonly icon: AppSymbolName; readonly onPress: () => void; readonly disabled?: boolean; + readonly selected?: boolean; } export function AndroidHeaderIconButton(props: { @@ -18,73 +23,62 @@ export function AndroidHeaderIconButton(props: { readonly icon: AppSymbolName; readonly onPress?: () => void; readonly disabled?: boolean; + readonly selected?: boolean; }) { - return ( - - - - ); + return ; } export function AndroidScreenHeader(props: { readonly title: string; readonly subtitle?: string | null; readonly actions?: ReadonlyArray; + readonly leading?: ReactNode; readonly trailing?: ReactNode; readonly onBack?: () => void; readonly embedded?: boolean; readonly hideBottomBorder?: boolean; }) { const insets = useSafeAreaInsets(); + const titleTypography = useScaledTextRole("title"); + const subtitleTypography = useScaledTextRole("label"); + const materialToolbarHeight = useMaterialToolbarHeight(); + const [headerWidth, setHeaderWidth] = useState(0); + const actions = props.actions ?? []; + const directCount = actions.length > 2 ? (headerWidth >= 600 ? 3 : 1) : actions.length; + const visibleActions = actions.slice(0, directCount); + const overflowActions = actions.slice(directCount); return ( setHeaderWidth(event.nativeEvent.layout.width)} + className="border-b border-header-border bg-header px-2 pb-2" style={{ paddingTop: props.embedded ? 8 : Math.max(insets.top, 12), borderBottomWidth: props.hideBottomBorder ? 0 : undefined, }} > - + {props.onBack ? ( - - - + /> ) : null} + {props.leading} + - + {props.title} {props.subtitle ? ( {props.subtitle} @@ -92,15 +86,39 @@ export function AndroidScreenHeader(props: { ) : null} - {props.actions?.map((action) => ( + {visibleActions.map((action) => ( ))} + {overflowActions.length > 0 ? ( + ({ + id: String(index), + title: action.accessibilityLabel, + attributes: { + disabled: Boolean(action.disabled), + state: action.selected ? "on" : undefined, + }, + }))} + onPressAction={({ nativeEvent }) => + overflowActions[Number(nativeEvent.event)]?.onPress() + } + > + {(open) => ( + + )} + + ) : null} {props.trailing} diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index e5d0137ee406..b65fb769e75e 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -9,6 +9,7 @@ import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle"; import IconApps from "@tabler/icons-react-native/IconApps"; import IconArchive from "@tabler/icons-react-native/IconArchive"; import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp"; +import IconArrowLeft from "@tabler/icons-react-native/IconArrowLeft"; import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle"; import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle"; import IconArrowUp from "@tabler/icons-react-native/IconArrowUp"; @@ -36,8 +37,10 @@ import IconClock from "@tabler/icons-react-native/IconClock"; import IconCode from "@tabler/icons-react-native/IconCode"; import IconCopy from "@tabler/icons-react-native/IconCopy"; import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop"; +import IconDatabase from "@tabler/icons-react-native/IconDatabase"; import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop"; import IconDots from "@tabler/icons-react-native/IconDots"; +import IconDotsVertical from "@tabler/icons-react-native/IconDotsVertical"; import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; import IconEdit from "@tabler/icons-react-native/IconEdit"; import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; @@ -79,6 +82,8 @@ import IconServer from "@tabler/icons-react-native/IconServer"; import IconSettings from "@tabler/icons-react-native/IconSettings"; import IconSparkles from "@tabler/icons-react-native/IconSparkles"; import IconStack2 from "@tabler/icons-react-native/IconStack2"; +import IconStar from "@tabler/icons-react-native/IconStar"; +import IconStarFilled from "@tabler/icons-react-native/IconStarFilled"; import IconStethoscope from "@tabler/icons-react-native/IconStethoscope"; import IconSun from "@tabler/icons-react-native/IconSun"; import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; @@ -97,6 +102,7 @@ import { withUniwind } from "uniwind"; const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "arrow.branch": IconGitBranch, + "arrow.left": IconArrowLeft, "arrow.clockwise": IconRefresh, "arrow.down.circle": IconArrowDownCircle, "arrow.right.circle": IconArrowRightCircle, @@ -142,7 +148,10 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "folder.badge.plus": IconFolderPlus, "folder.fill": IconFolder, gearshape: IconSettings, + hammer: IconHammer, "info.circle": IconInfoCircle, + internaldrive: IconDatabase, + keyboard: IconKeyboard, laptopcomputer: IconDeviceLaptop, link: IconLink, "line.3.horizontal": IconMenu2, @@ -160,6 +169,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "pin.slash": IconPinnedOff, play: IconPlayerPlay, plus: IconPlus, + minus: IconMinus, "qrcode.viewfinder": IconQrcode, "point.3.connected.trianglepath.dotted": IconNetwork, "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge, @@ -172,6 +182,8 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "square.and.pencil": IconEdit, "square.grid.2x2": IconApps, "square.split.2x1": IconLayoutColumns, + star: IconStar, + "star.fill": IconStarFilled, "sun.max": IconSun, "stop.fill": IconPlayerStopFilled, terminal: IconTerminal2, @@ -209,6 +221,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record = { keyboard_arrow_down: IconChevronDown, keyboard_arrow_up: IconChevronUp, keyboard_hide: IconKeyboardHide, + more_vert: IconDotsVertical, public: IconWorld, remove: IconMinus, terminal: IconTerminal2, diff --git a/apps/mobile/src/components/AppText.tsx b/apps/mobile/src/components/AppText.tsx index 6501d2044083..805cdb66a3b4 100644 --- a/apps/mobile/src/components/AppText.tsx +++ b/apps/mobile/src/components/AppText.tsx @@ -1,4 +1,5 @@ import { + Platform, Text as RNText, TextInput as RNTextInput, type TextInputProps as RNTextInputProps, @@ -14,7 +15,13 @@ export type AppTextProps = RNTextProps & { readonly className?: string }; * Uses Uniwind className — no manual style parsing. */ export function AppText({ className, ...props }: AppTextProps) { - return ; + return ( + + ); } export type AppTextInputProps = Omit & { @@ -35,8 +42,13 @@ export function AppTextInput({ className, ref, ...props }: AppTextInputProps) { className, )} placeholderTextColorClassName="accent-placeholder" - selectionColorClassName="accent-foreground-secondary" - cursorColorClassName="accent-foreground-secondary" + selectionColorClassName={ + Platform.OS === "android" ? "accent-primary/32" : "accent-foreground-secondary" + } + cursorColorClassName={ + Platform.OS === "android" ? "accent-primary" : "accent-foreground-secondary" + } + selectionHandleColorClassName={Platform.OS === "android" ? "accent-primary" : undefined} {...props} /> ); diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index f9112d78fb41..a697ad6bc11f 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -155,8 +155,9 @@ export function ComposerEditor({ const selectedReference = selected ? collectComposerContextReferences(selected.source)[0] : undefined; - const selectedSkill = selected?.source.startsWith("$") - ? props.skills?.find((skill) => skill.name === selected.source.slice(1)) + const selectedSkillName = selected?.source.match(/^\p{Sc}(.+)$/u)?.[1]; + const selectedSkill = selectedSkillName + ? props.skills?.find((skill) => skill.name === selectedSkillName) : undefined; const record = draft.context?.records.find( (entry) => entry.contextId === selectedReference?.contextId, diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index d7db39d39ae1..aa1653055b59 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useState } from "react"; -import { Modal, Pressable, TextInput, View } from "react-native"; +import { Platform, Modal, Pressable, TextInput, View } from "react-native"; import { cn } from "../lib/cn"; import { AppText } from "./AppText"; +import { MaterialConfirmDialog } from "./MaterialConfirmDialog"; export type ConfirmDialogRequest = { readonly title: string; @@ -67,17 +68,35 @@ export function ConfirmDialogHost() { setPresented(null); }, [presented]); - const handleConfirm = useCallback(() => { - if (presented?.kind === "confirm") { - presented.request.onConfirm(); - } else if (presented?.kind === "text-input") { - presented.request.onConfirm(inputValue); - } - setPresented(null); - }, [inputValue, presented]); + const handleConfirm = useCallback( + (nativeInputValue?: string) => { + if (presented?.kind === "confirm") { + presented.request.onConfirm(); + } else if (presented?.kind === "text-input") { + presented.request.onConfirm(nativeInputValue ?? inputValue); + } + setPresented(null); + }, + [inputValue, presented], + ); const confirmDisabled = presented?.kind === "text-input" && inputValue.trim().length === 0; + if (Platform.OS === "android") + return presented ? ( + + ) : null; + return ( handleConfirm()} returnKeyType="done" selectTextOnFocus value={inputValue} @@ -125,7 +144,7 @@ export function ConfirmDialogHost() { accessibilityRole="button" disabled={confirmDisabled} className="min-h-10 items-center justify-center px-4 active:bg-subtle" - onPress={handleConfirm} + onPress={() => handleConfirm()} > + ); + } + + if ( + Platform.OS === "android" && + props.accessibilityLabel && + props.icon && + !props.iconNode && + !props.label && + !props.className && + !props.activateOnPressIn + ) { + return ( + + ); + } + return ( , "children" | "themeVariant"> & Pick & { diff --git a/apps/mobile/src/components/EmptyState.tsx b/apps/mobile/src/components/EmptyState.tsx index ce068bc46132..5fb4ce08f2f7 100644 --- a/apps/mobile/src/components/EmptyState.tsx +++ b/apps/mobile/src/components/EmptyState.tsx @@ -1,4 +1,5 @@ import { Pressable, View } from "react-native"; +import type { ReactNode } from "react"; import { AppText as Text } from "./AppText"; @@ -7,6 +8,7 @@ export function EmptyState(props: { readonly detail: string; readonly actionLabel?: string; readonly onAction?: () => void; + readonly action?: ReactNode; readonly variant?: "card" | "plain"; }) { if (props.variant === "plain") { @@ -16,7 +18,9 @@ export function EmptyState(props: { {props.detail} - {props.actionLabel && props.onAction ? ( + {props.action ? ( + {props.action} + ) : props.actionLabel && props.onAction ? ( {props.detail} - {props.actionLabel && props.onAction ? ( + {props.action ? ( + {props.action} + ) : props.actionLabel && props.onAction ? ( ; -}) { +export function GlassBackdrop(props: { readonly fallbackColor?: ColorValue }) { const { themeAppearance } = useAppearancePreferences(); - const inheritedBlurTarget = useContext(GlassBlurTargetContext); - const target = props.blurTarget ?? inheritedBlurTarget; - const supportsBlur = - Platform.OS === "ios" || - (Platform.OS === "android" && Platform.Version >= 31 && target !== undefined); + const supportsBlur = Platform.OS === "ios"; const colorStyle = props.fallbackColor === undefined ? undefined @@ -24,17 +15,9 @@ export function GlassBackdrop(props: { return ( <> - {/* Android samples a separate target. An opaque backing prevents any - transparent pixels in that sample from exposing the unblurred feed. - iOS samples its actual backdrop, so a backing there would hide it. */} - {Platform.OS === "android" ? ( - - ) : null} {supportsBlur ? ( ; /** Uniwind styling used only when native Liquid Glass is unavailable. */ readonly fallbackClassName?: string; } @@ -41,7 +40,6 @@ export function GlassSurface({ tintColor, tintColorClassName, fallbackColor, - blurTarget, fallbackClassName, className, style, @@ -49,23 +47,15 @@ export function GlassSurface({ }: GlassSurfaceProps) { const isDarkMode = useColorScheme() === "dark"; const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); + const hasShadow = chrome !== "none" && Platform.OS !== "android"; const surfaceStyle: ViewStyle = { borderRadius: 32, overflow: "hidden", - shadowColor: chrome === "none" ? "transparent" : "#000000", - shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08, - shadowRadius: chrome === "none" ? 0 : 28, - shadowOffset: - chrome === "none" - ? { - width: 0, - height: 0, - } - : { - width: 0, - height: 14, - }, - elevation: chrome === "none" ? 0 : 12, + shadowColor: hasShadow ? "#000000" : "transparent", + shadowOpacity: hasShadow ? (isDarkMode ? 0.22 : 0.08) : 0, + shadowRadius: hasShadow ? 28 : 0, + shadowOffset: { width: 0, height: hasShadow ? 14 : 0 }, + elevation: hasShadow ? 12 : 0, }; if (supportsGlass) { @@ -103,7 +93,7 @@ export function GlassSurface({ )} style={[surfaceStyle, style]} > - + {children} ); diff --git a/apps/mobile/src/components/MaterialButton.android.tsx b/apps/mobile/src/components/MaterialButton.android.tsx new file mode 100644 index 000000000000..b5a8417fc799 --- /dev/null +++ b/apps/mobile/src/components/MaterialButton.android.tsx @@ -0,0 +1,94 @@ +import { + Box, + Button, + CircularProgressIndicator, + FilledTonalButton, + Host, + Row, + Text, + TextButton, +} from "@expo/ui/jetpack-compose"; +import { defaultMinSize, fillMaxWidth, size } from "@expo/ui/jetpack-compose/modifiers"; +import { View } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; +import type { MaterialButtonProps } from "./MaterialButton"; + +export function MaterialButton(props: MaterialButtonProps) { + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const typography = useScaledTextRole("footnote"); + const tone = props.tone ?? "secondary"; + const Component = + tone === "text" ? TextButton : tone === "secondary" ? FilledTonalButton : Button; + const containerColor = + tone === "primary" + ? colors["--color-primary"] + : tone === "danger" + ? colors["--color-danger"] + : tone === "text" + ? "#00000000" + : colors["--color-secondary"]; + const contentColor = + tone === "primary" + ? colors["--color-primary-foreground"] + : tone === "danger" + ? colors["--color-danger-foreground"] + : tone === "text" + ? colors["--color-primary"] + : colors["--color-secondary-foreground"]; + return ( + { + if (!props.disabled && !props.loading) props.onPress(); + }} + style={props.fullWidth ? { width: "100%" } : { alignSelf: "flex-start" }} + > + + + + + {props.loading ? ( + <> + + + + ) : null} + {props.label} + + + + + + ); +} diff --git a/apps/mobile/src/components/MaterialButton.tsx b/apps/mobile/src/components/MaterialButton.tsx new file mode 100644 index 000000000000..f0ad4cb8f55c --- /dev/null +++ b/apps/mobile/src/components/MaterialButton.tsx @@ -0,0 +1,24 @@ +import { Pressable } from "react-native"; +import { AppText } from "./AppText"; + +export interface MaterialButtonProps { + readonly label: string; + readonly onPress: () => void; + readonly disabled?: boolean; + readonly loading?: boolean; + readonly tone?: "primary" | "secondary" | "danger" | "text"; + readonly fullWidth?: boolean; +} + +export function MaterialButton(props: MaterialButtonProps) { + return ( + + {props.label} + + ); +} diff --git a/apps/mobile/src/components/MaterialConfirmDialog.android.tsx b/apps/mobile/src/components/MaterialConfirmDialog.android.tsx new file mode 100644 index 000000000000..3e65d7185ecc --- /dev/null +++ b/apps/mobile/src/components/MaterialConfirmDialog.android.tsx @@ -0,0 +1,96 @@ +import { + AlertDialog, + Host, + OutlinedTextField, + Text, + TextButton, + useNativeState, +} from "@expo/ui/jetpack-compose"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; +import type { MaterialConfirmDialogProps } from "./MaterialConfirmDialog"; + +export function MaterialConfirmDialog(props: MaterialConfirmDialogProps) { + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const titleTypography = useScaledTextRole("title"); + const bodyTypography = useScaledTextRole("footnote"); + const inputTypography = useScaledTextRole("body"); + const inputState = useNativeState(props.inputInitialValue ?? ""); + const inputSelection = useNativeState({ start: 0, end: props.inputInitialValue?.length ?? 0 }); + const confirm = () => { + if (props.confirmDisabled) return; + const value = props.inputInitialValue === undefined ? undefined : inputState.get(); + if (value !== undefined && !value.trim()) return; + props.onConfirm(value); + }; + return ( + + + + {props.request.title} + + {props.inputInitialValue !== undefined ? ( + + + + {props.request.title} + + + + ) : props.request.message ? ( + + {props.request.message} + + ) : null} + + + {props.request.cancelText ?? "Cancel"} + + + + + {props.request.confirmText} + + + + + ); +} diff --git a/apps/mobile/src/components/MaterialConfirmDialog.tsx b/apps/mobile/src/components/MaterialConfirmDialog.tsx new file mode 100644 index 000000000000..c21ad042c39c --- /dev/null +++ b/apps/mobile/src/components/MaterialConfirmDialog.tsx @@ -0,0 +1,17 @@ +import type { ConfirmDialogRequest } from "./ConfirmDialogHost"; + +export interface MaterialConfirmDialogProps { + readonly request: Pick< + ConfirmDialogRequest, + "title" | "message" | "cancelText" | "confirmText" | "destructive" + >; + readonly inputInitialValue?: string; + readonly onInputChange?: (value: string) => void; + readonly confirmDisabled?: boolean; + readonly onCancel: () => void; + readonly onConfirm: (inputValue?: string) => void; +} + +export function MaterialConfirmDialog(_props: MaterialConfirmDialogProps) { + return null; +} diff --git a/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx b/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx new file mode 100644 index 000000000000..3978712683c0 --- /dev/null +++ b/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx @@ -0,0 +1,86 @@ +import { + Box, + ExtendedFloatingActionButton, + FloatingActionButton, + Host, + LargeFloatingActionButton, + Text, +} from "@expo/ui/jetpack-compose"; +import { size } from "@expo/ui/jetpack-compose/modifiers"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; +import { SymbolView, type AppSymbolName } from "./AppSymbol"; + +export function MaterialFloatingActionButton(props: { + readonly onPress: () => void; + readonly label: string; + readonly icon: AppSymbolName; + readonly variant?: "extended" | "large"; + readonly expanded?: boolean; + readonly tone?: "primary" | "secondary"; + readonly className?: string; + readonly style?: StyleProp; +}) { + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const typography = useScaledTextRole("footnote"); + const primary = props.tone === "primary"; + const containerColor = colors[primary ? "--color-primary" : "--color-thread-selected"]; + const contentColor = + colors[primary ? "--color-primary-foreground" : "--color-thread-selected-foreground"]; + const Component = + props.variant === "extended" + ? ExtendedFloatingActionButton + : props.variant === "large" + ? LargeFloatingActionButton + : FloatingActionButton; + const iconSize = props.variant === "large" ? 36 : 24; + return ( + + + + + + + + {props.variant === "extended" ? ( + + + {props.label} + + + ) : null} + + + + {/* The RN icon stays outside Compose so it cannot intercept native button taps. */} + + + + + ); +} diff --git a/apps/mobile/src/components/MaterialFloatingActionButton.tsx b/apps/mobile/src/components/MaterialFloatingActionButton.tsx new file mode 100644 index 000000000000..eeed5fd1d138 --- /dev/null +++ b/apps/mobile/src/components/MaterialFloatingActionButton.tsx @@ -0,0 +1,46 @@ +import type { ComponentProps } from "react"; +import { Pressable } from "react-native"; +import type { MaterialFloatingActionButton as AndroidMaterialFloatingActionButton } from "./MaterialFloatingActionButton.android"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import { cn } from "../lib/cn"; + +export function MaterialFloatingActionButton( + props: ComponentProps, +) { + return ( + + + {props.variant === "extended" && props.expanded !== false ? ( + + {props.label} + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/MaterialIconButton.android.tsx b/apps/mobile/src/components/MaterialIconButton.android.tsx new file mode 100644 index 000000000000..9cc6f5dc6ce0 --- /dev/null +++ b/apps/mobile/src/components/MaterialIconButton.android.tsx @@ -0,0 +1,87 @@ +import { + FilledIconButton, + FilledTonalIconButton, + Host, + IconButton, +} from "@expo/ui/jetpack-compose"; +import { size } from "@expo/ui/jetpack-compose/modifiers"; +import { View } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { SymbolView, type AppSymbolName } from "./AppSymbol"; + +export function MaterialIconButton(props: { + readonly accessibilityLabel: string; + readonly icon: AppSymbolName; + readonly onPress?: () => void; + readonly disabled?: boolean; + readonly selected?: boolean; + readonly variant?: "standard" | "primary" | "tonal" | "danger"; +}) { + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const variant = props.variant ?? "standard"; + const Component = + variant === "standard" + ? IconButton + : variant === "tonal" + ? FilledTonalIconButton + : FilledIconButton; + const containerColor = + variant === "primary" + ? colors["--color-primary"] + : variant === "danger" + ? colors["--color-danger"] + : colors["--color-secondary"]; + const iconTint = props.disabled + ? "accent-icon-subtle" + : variant === "primary" + ? "accent-primary-foreground" + : variant === "danger" + ? "accent-danger-foreground" + : variant === "tonal" + ? "accent-secondary-foreground" + : "accent-foreground"; + return ( + { + if (!props.disabled) props.onPress?.(); + }} + style={{ width: 48, height: 48 }} + > + + + + {null} + + + + {/* Keep RN SVG measurement outside Compose; the native button owns touch and ripple. */} + + + + + ); +} diff --git a/apps/mobile/src/components/MaterialIconButton.tsx b/apps/mobile/src/components/MaterialIconButton.tsx new file mode 100644 index 000000000000..852889140ad5 --- /dev/null +++ b/apps/mobile/src/components/MaterialIconButton.tsx @@ -0,0 +1,23 @@ +import { Pressable } from "react-native"; + +import { SymbolView, type AppSymbolName } from "./AppSymbol"; + +export function MaterialIconButton(props: { + readonly accessibilityLabel: string; + readonly icon: AppSymbolName; + readonly onPress?: () => void; + readonly disabled?: boolean; + readonly selected?: boolean; + readonly variant?: "standard" | "primary" | "tonal" | "danger"; +}) { + return ( + + + + ); +} diff --git a/apps/mobile/src/components/MaterialListRow.tsx b/apps/mobile/src/components/MaterialListRow.tsx new file mode 100644 index 000000000000..31d6f7a6ffd1 --- /dev/null +++ b/apps/mobile/src/components/MaterialListRow.tsx @@ -0,0 +1,57 @@ +import type { ComponentProps, ReactNode } from "react"; +import { Platform, Pressable, View } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { cn } from "../lib/cn"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; + +/** Shared geometry for Material navigation and selection lists. Group rows in one card. */ +export function MaterialListRow({ + title, + subtitle, + leading, + trailing, + className, + ...props +}: Omit, "children"> & { + readonly title: string; + readonly subtitle?: string | null; + readonly leading?: ReactNode; + readonly trailing?: ReactNode; +}) { + const { themeVariables } = useAppearancePreferences(); + return ( + + {leading ? {leading} : null} + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {trailing !== undefined ? ( + trailing + ) : !props.disabled ? ( + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/MaterialMenuPopup.android.tsx b/apps/mobile/src/components/MaterialMenuPopup.android.tsx new file mode 100644 index 000000000000..87b467af9675 --- /dev/null +++ b/apps/mobile/src/components/MaterialMenuPopup.android.tsx @@ -0,0 +1,153 @@ +import { + Box, + Column, + DropdownMenu, + DropdownMenuItem, + Host, + RNHostView, + Text, +} from "@expo/ui/jetpack-compose"; +import { padding, size, width } from "@expo/ui/jetpack-compose/modifiers"; +import { View } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import type { MaterialMenuPopupProps } from "./MaterialMenuPopup"; +import { SymbolView, type AppSymbolName } from "./AppSymbol"; + +function MenuIcon(props: { + readonly name: AppSymbolName; + readonly destructive?: boolean; + readonly disabled?: boolean; +}) { + return ( + + + + + + ); +} + +/** Native popup positioned at the original trigger, outside virtualized rows. */ +export function MaterialMenuPopup(props: MaterialMenuPopupProps) { + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const foreground = colors["--color-foreground"]; + const muted = colors["--color-foreground-muted"]; + const items = ( + <> + {props.parent ? ( + + + + + + + {props.parent.title} + + + + ) : props.title ? ( + + {props.title} + + ) : null} + {props.actions.map((action, index) => ( + props.onPress(action)} + > + + + + {action.title} + + {action.subtitle ? ( + + {action.subtitle} + + ) : null} + + + {action.image ? ( + + + + ) : null} + {(action.subactions?.length ?? 0) > 0 ? ( + + + + ) : action.state === "on" ? ( + + + + ) : null} + + ))} + + ); + if (props.inline) { + return ( + + {items} + + ); + } + return ( + + + + + + {items} + + + ); +} diff --git a/apps/mobile/src/components/MaterialMenuPopup.tsx b/apps/mobile/src/components/MaterialMenuPopup.tsx new file mode 100644 index 000000000000..3a23e5de7ab4 --- /dev/null +++ b/apps/mobile/src/components/MaterialMenuPopup.tsx @@ -0,0 +1,22 @@ +import type { MenuAction } from "@react-native-menu/menu"; + +export interface MaterialMenuPopupProps { + readonly anchor: { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + }; + readonly actions: readonly MenuAction[]; + readonly title?: string; + readonly parent: MenuAction | null; + readonly onPress: (action: MenuAction) => void; + readonly onBack: () => void; + readonly onClose: () => void; + /** Keep the editor's window focus and keyboard while showing native menu rows. */ + readonly inline?: boolean; +} + +export function MaterialMenuPopup(_props: MaterialMenuPopupProps) { + return null; +} diff --git a/apps/mobile/src/components/MaterialNewThreadButton.android.tsx b/apps/mobile/src/components/MaterialNewThreadButton.android.tsx new file mode 100644 index 000000000000..cd76633d8935 --- /dev/null +++ b/apps/mobile/src/components/MaterialNewThreadButton.android.tsx @@ -0,0 +1,21 @@ +import type { ComponentProps } from "react"; +import { MaterialFloatingActionButton } from "./MaterialFloatingActionButton.android"; +import { MaterialScrollComposeButton } from "./MaterialScrollComposeButton.android"; +import type { MaterialNewThreadButton as SharedMaterialNewThreadButton } from "./MaterialNewThreadButton.shared"; + +export function MaterialNewThreadButton( + props: ComponentProps, +) { + if (props.extended && props.expanded !== undefined) { + return ; + } + return ( + + ); +} diff --git a/apps/mobile/src/components/MaterialNewThreadButton.shared.tsx b/apps/mobile/src/components/MaterialNewThreadButton.shared.tsx new file mode 100644 index 000000000000..2c887ce78a71 --- /dev/null +++ b/apps/mobile/src/components/MaterialNewThreadButton.shared.tsx @@ -0,0 +1,40 @@ +import { Pressable, type StyleProp, type ViewStyle } from "react-native"; + +import { cn } from "../lib/cn"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; + +/** Shared compose action for the floating button and empty workspace. */ +export function MaterialNewThreadButton(props: { + readonly onPress: () => void; + readonly extended?: boolean; + readonly expanded?: boolean; + readonly className?: string; + readonly style?: StyleProp; +}) { + return ( + + + {props.extended && props.expanded !== false ? ( + New thread + ) : null} + + ); +} diff --git a/apps/mobile/src/components/MaterialNewThreadButton.tsx b/apps/mobile/src/components/MaterialNewThreadButton.tsx new file mode 100644 index 000000000000..d197cf97d62d --- /dev/null +++ b/apps/mobile/src/components/MaterialNewThreadButton.tsx @@ -0,0 +1 @@ +export { MaterialNewThreadButton } from "./MaterialNewThreadButton.shared"; diff --git a/apps/mobile/src/components/MaterialRadioIndicator.android.tsx b/apps/mobile/src/components/MaterialRadioIndicator.android.tsx new file mode 100644 index 000000000000..2484dc4536ba --- /dev/null +++ b/apps/mobile/src/components/MaterialRadioIndicator.android.tsx @@ -0,0 +1,55 @@ +import { Host, RadioButton } from "@expo/ui/jetpack-compose"; +import { size } from "@expo/ui/jetpack-compose/modifiers"; +import { View } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; + +/** The enclosing radio row owns selection, touch and accessibility. */ +export function MaterialRadioIndicator({ selected }: { readonly selected: boolean }) { + const { themeAppearance, themeVariables, systemColorsActive } = useAppearancePreferences(); + // Expo's radio button cannot override colors in the pinned SDK. Custom + // themes need their exact accent rather than a generated Material palette. + if (!systemColorsActive) { + return ( + + + {selected ? ( + + ) : null} + + + ); + } + return ( + + + + + + ); +} diff --git a/apps/mobile/src/components/MaterialRadioIndicator.tsx b/apps/mobile/src/components/MaterialRadioIndicator.tsx new file mode 100644 index 000000000000..fb70e90b3439 --- /dev/null +++ b/apps/mobile/src/components/MaterialRadioIndicator.tsx @@ -0,0 +1,8 @@ +import { SymbolView } from "./AppSymbol"; + +/** The enclosing radio row owns selection, touch and accessibility. */ +export function MaterialRadioIndicator({ selected }: { readonly selected: boolean }) { + return selected ? ( + + ) : null; +} diff --git a/apps/mobile/src/components/MaterialScreenContent.tsx b/apps/mobile/src/components/MaterialScreenContent.tsx new file mode 100644 index 000000000000..44d4cc093b6d --- /dev/null +++ b/apps/mobile/src/components/MaterialScreenContent.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; +import { Platform, View } from "react-native"; + +/** Keeps the header surface visible behind rounded Android content corners. */ +export function MaterialScreenContent({ + children, + insetHorizontal = false, + fitToContents = false, +}: { + readonly children: ReactNode; + /** Match the master-list gutters for secondary panes, not full-width content. */ + readonly insetHorizontal?: boolean; + /** Allow native form sheets to measure their content instead of filling a fixed detent. */ + readonly fitToContents?: boolean; +}) { + if (Platform.OS !== "android") return children; + + return ( + + + {children} + + + ); +} diff --git a/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx b/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx new file mode 100644 index 000000000000..018dbdc19b42 --- /dev/null +++ b/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx @@ -0,0 +1,75 @@ +import { Box, ExtendedFloatingActionButton, Host, Icon, Text } from "@expo/ui/jetpack-compose"; +import { fillMaxWidth, onSizeChanged, size } from "@expo/ui/jetpack-compose/modifiers"; +import { useCallback, useState } from "react"; +import { Pressable, View, type StyleProp, type ViewStyle } from "react-native"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; + +/** Keep the animated width and icon positioning entirely inside Compose, not Yoga. */ +export function MaterialScrollComposeButton(props: { + readonly expanded: boolean; + readonly onPress: () => void; + readonly className?: string; + readonly style?: StyleProp; +}) { + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const typography = useScaledTextRole("footnote"); + const [expandedWidth, setExpandedWidth] = useState(56); + const rememberWidth = useCallback(({ width }: { width: number }) => { + setExpandedWidth((previous) => Math.max(previous, width)); + }, []); + return ( + + + + + + + + + + + + + New thread + + + + + + + {/* The visual host is wider than the button; only this target intercepts list touches. */} + + + ); +} diff --git a/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx new file mode 100644 index 000000000000..ceea91b24447 --- /dev/null +++ b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx @@ -0,0 +1,37 @@ +import { SegmentedButton, SingleChoiceSegmentedButtonRow, Text } from "@expo/ui/jetpack-compose"; +import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; +import type { SegmentedControlProps } from "./SegmentedControl"; + +/** Compose content shared by screen controls and native dialogs, inside their existing Host. */ +export function MaterialSegmentedButtons( + props: SegmentedControlProps, +) { + const { themeVariables: colors } = useAppearancePreferences(); + const typography = useScaledTextRole("footnote"); + return ( + + {props.options.map((option) => ( + props.onSelect(option.value)} + modifiers={[defaultMinSize({ minHeight: 48 })]} + colors={{ + activeContainerColor: colors["--color-secondary"], + activeContentColor: colors["--color-secondary-foreground"], + inactiveContainerColor: "transparent", + inactiveContentColor: colors["--color-foreground"], + activeBorderColor: colors["--color-border"], + inactiveBorderColor: colors["--color-border"], + }} + > + + {option.label} + + + ))} + + ); +} diff --git a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx new file mode 100644 index 000000000000..16ec879821e1 --- /dev/null +++ b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx @@ -0,0 +1,40 @@ +import { Host } from "@expo/ui/jetpack-compose"; +import { MaterialSegmentedButtons } from "./MaterialSegmentedButtons.android"; +import { View } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import type { SegmentedControlProps } from "./SegmentedControl"; + +export function MaterialSegmentedControl( + props: SegmentedControlProps, +) { + const { themeAppearance } = useAppearancePreferences(); + return ( + + + + + + + + {props.options.map((option) => ( + props.onSelect(option.value)} + className="flex-1" + /> + ))} + + + ); +} diff --git a/apps/mobile/src/components/MaterialSegmentedControl.tsx b/apps/mobile/src/components/MaterialSegmentedControl.tsx new file mode 100644 index 000000000000..eca51d2d3f26 --- /dev/null +++ b/apps/mobile/src/components/MaterialSegmentedControl.tsx @@ -0,0 +1,7 @@ +import type { SegmentedControlProps } from "./SegmentedControl"; + +export function MaterialSegmentedControl( + _props: SegmentedControlProps, +) { + return null; +} diff --git a/apps/mobile/src/components/MaterialSwitch.android.tsx b/apps/mobile/src/components/MaterialSwitch.android.tsx new file mode 100644 index 000000000000..2bc9d11130d7 --- /dev/null +++ b/apps/mobile/src/components/MaterialSwitch.android.tsx @@ -0,0 +1,49 @@ +import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose"; +import { View } from "react-native"; +import type { ThemedSwitchProps } from "./ThemedSwitch"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; + +/** Material's native switch, with the same palette and accessibility contract as our RN controls. */ +export function MaterialSwitch(props: ThemedSwitchProps) { + const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const toggle = () => { + if (!props.disabled) props.onValueChange?.(!props.value); + }; + + return ( + + + + + + + + ); +} diff --git a/apps/mobile/src/components/MaterialSwitch.tsx b/apps/mobile/src/components/MaterialSwitch.tsx new file mode 100644 index 000000000000..b0e8346ced51 --- /dev/null +++ b/apps/mobile/src/components/MaterialSwitch.tsx @@ -0,0 +1 @@ +export { Switch as MaterialSwitch } from "react-native"; diff --git a/apps/mobile/src/components/ScreenScrollView.tsx b/apps/mobile/src/components/ScreenScrollView.tsx new file mode 100644 index 000000000000..509feeb39728 --- /dev/null +++ b/apps/mobile/src/components/ScreenScrollView.tsx @@ -0,0 +1,19 @@ +import type { ComponentProps } from "react"; +import { Platform, ScrollView } from "react-native"; + +/** Keeps forms and settings readable inside a wide pane while its surface fills the screen. */ +export function ScreenScrollView(props: ComponentProps) { + return ( + + ); +} diff --git a/apps/mobile/src/components/SegmentedControl.tsx b/apps/mobile/src/components/SegmentedControl.tsx index 04a562956c46..607062e0579e 100644 --- a/apps/mobile/src/components/SegmentedControl.tsx +++ b/apps/mobile/src/components/SegmentedControl.tsx @@ -2,8 +2,9 @@ import { Platform, Pressable, View } from "react-native"; import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-reanimated"; import { AppText as Text } from "./AppText"; import { cn } from "../lib/cn"; +import { MaterialSegmentedControl } from "./MaterialSegmentedControl"; -export function SegmentedControl(props: { +export interface SegmentedControlProps { readonly options: readonly { readonly value: Value; readonly label: string; @@ -11,18 +12,26 @@ export function SegmentedControl(props: { }[]; readonly selected: Value; readonly onSelect: (value: Value) => void; - /** The tab bar is full height; filters under it are shorter so it stays primary. */ + /** Compact sizing applies to the non-Material control. */ readonly size?: "default" | "compact"; /** "tab" for the view switcher; filters stay plain buttons. */ readonly role?: "tab" | "button"; readonly className?: string; -}) { +} + +export function SegmentedControl( + props: SegmentedControlProps, +) { const compact = props.size === "compact"; + if (Platform.OS === "android") { + return ; + } return ( @@ -31,7 +40,7 @@ export function SegmentedControl(props: { layout={LinearTransition.duration(200) .easing(Easing.out(Easing.cubic)) .reduceMotion(ReduceMotion.System)} - className="absolute bottom-0 top-0 rounded-full bg-subtle-strong" + className="absolute inset-y-0 rounded-full bg-subtle-strong" style={{ width: `${100 / props.options.length}%`, start: `${ diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx index 08cef56f3ec0..f0cf8701e50c 100644 --- a/apps/mobile/src/components/ThemedSwitch.tsx +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -1,63 +1,27 @@ -import { Platform, Pressable, Switch, View, type SwitchProps } from "react-native"; +import { Platform, Switch, type SwitchProps } from "react-native"; -import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; -import { SymbolView } from "./AppSymbol"; +import { MaterialSwitch } from "./MaterialSwitch"; -export function ThemedSwitch(props: SwitchProps) { - const { materialYouStyleLayoutActive } = useAppearancePreferences(); - if (materialYouStyleLayoutActive) { - return ( - props.onValueChange?.(!props.value)} - style={props.style} - testID={props.testID} - className={props.disabled ? "opacity-40" : "active:opacity-70"} - > - - - - - - - ); +export type ThemedSwitchProps = Pick< + SwitchProps, + | "accessibilityHint" + | "accessibilityLabel" + | "disabled" + | "onValueChange" + | "style" + | "testID" + | "value" +>; + +export function ThemedSwitch(props: ThemedSwitchProps) { + if (Platform.OS === "android") { + return ; } return ( diff --git a/apps/mobile/src/components/useMaterialToolbarHeight.ts b/apps/mobile/src/components/useMaterialToolbarHeight.ts new file mode 100644 index 000000000000..26e8510a82ff --- /dev/null +++ b/apps/mobile/src/components/useMaterialToolbarHeight.ts @@ -0,0 +1,11 @@ +import { useWindowDimensions } from "react-native"; + +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; + +/** Reserve the same title/subtitle space in every pane, including icon-only and search headers. */ +export function useMaterialToolbarHeight() { + const title = useScaledTextRole("title"); + const subtitle = useScaledTextRole("label"); + const { fontScale } = useWindowDimensions(); + return Math.ceil(Math.max(56, (title.lineHeight + subtitle.lineHeight) * fontScale + 1)); +} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 97f9c5b69d0f..78b3b62a3c1d 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -40,6 +40,7 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; +import { SettingsScreenContent } from "../settings/components/SettingsScreen"; export interface ArchivedThreadsHeaderEnvironment { readonly environmentId: EnvironmentId; @@ -146,6 +147,7 @@ function ArchivedThreadsHeader(props: { className="border-b border-header-border bg-header px-3 pb-2.5" style={{ paddingTop: Math.max(insets.top, 12), + borderBottomWidth: 0, }} > @@ -159,7 +161,7 @@ function ArchivedThreadsHeader(props: { @@ -167,7 +169,7 @@ function ArchivedThreadsHeader(props: { @@ -455,7 +457,7 @@ function ArchivedThreadRow(props: { @@ -477,7 +479,7 @@ function ArchivedThreadRow(props: { - + Loading archive... ); @@ -646,37 +648,39 @@ export function ArchivedThreadsScreen(props: { sortOrder={props.sortOrder} /> - - item.kind} - keyboardDismissMode="on-drag" - keyboardShouldPersistTaps="handled" - keyExtractor={(item) => item.key} - ListEmptyComponent={listEmptyComponent} - ListHeaderComponent={ - props.error ? : null - } - onScrollBeginDrag={() => openSwipeableRef.current?.close()} - refreshControl={ - - } - renderItem={renderListItem} - showsVerticalScrollIndicator={false} - /> - + + + item.kind} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + keyExtractor={(item) => item.key} + ListEmptyComponent={listEmptyComponent} + ListHeaderComponent={ + props.error ? : null + } + onScrollBeginDrag={() => openSwipeableRef.current?.close()} + refreshControl={ + + } + renderItem={renderListItem} + showsVerticalScrollIndicator={false} + /> + + ); } diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx index 8138d288957c..df6f19835591 100644 --- a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -1,8 +1,9 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useAuth } from "@clerk/expo"; import { StackActions, useNavigation } from "@react-navigation/native"; import { useCallback, useEffect, useState } from "react"; -import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { Platform, Pressable, RefreshControl, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 42d9cdddd0e4..a42db147c17d 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -6,11 +6,13 @@ import { useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useState } from "react"; -import { Alert, Pressable, View } from "react-native"; +import { Platform, Alert, Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; +import { MaterialButton } from "../../components/MaterialButton"; +import { MaterialIconButton } from "../../components/MaterialIconButton"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; @@ -142,7 +144,7 @@ export function ConnectionEnvironmentRow(props: { )} - - {props.environment.isRelayManaged ? null : ( + {Platform.OS === "android" ? ( + + {props.environment.isRelayManaged ? null : ( + + { + void handleSave(); + }} + /> + + )} + props.onReconnect(props.environment.environmentId)} + /> + props.onRemove(props.environment.environmentId)} + /> + + ) : ( + + {props.environment.isRelayManaged ? null : ( + + + + Save + + + )} + props.onReconnect(props.environment.environmentId)} > - - Save - - )} - - props.onReconnect(props.environment.environmentId)} - > - - - props.onRemove(props.environment.environmentId)} - > - - - + props.onRemove(props.environment.environmentId)} + > + + + + )} ) : null} diff --git a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx index fe26c66a355e..e9ac17b665ab 100644 --- a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx +++ b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx @@ -3,6 +3,7 @@ import { Platform, Pressable } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; +import { MaterialButton } from "../../components/MaterialButton"; const CARD_SHADOW = Platform.select({ ios: { @@ -32,8 +33,19 @@ export function ConnectionSheetButton(props: { readonly disabled?: boolean; readonly tone?: "primary" | "secondary" | "danger"; readonly compact?: boolean; + readonly fullWidth?: boolean; readonly onPress: () => void; }) { + if (Platform.OS === "android") + return ( + + ); const tone = props.tone ?? "secondary"; const textColorClassName = diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 1645844d86b3..ea4f1cf69712 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -1,13 +1,18 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { CameraView, useCameraPermissions } from "expo-camera"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { + StackActions, + useNavigation, + useRoute, + type StaticScreenProps, +} from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Alert, Linking, Platform, ScrollView, View } from "react-native"; +import { Alert, Linking, Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; - -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { SettingsScreen } from "../settings/components/SettingsScreen"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; import { ConnectionSheetButton } from "./ConnectionSheetButton"; @@ -30,6 +35,7 @@ export function ConnectionsNewRouteScreen({ pairingConnectionError, } = useRemoteConnections(); const navigation = useNavigation(); + const routeName = useRoute().name; const params = route.params ?? {}; // Deep-link prefill exists for development automation only. A production // link must not arrive with attacker-chosen host and token already filled. @@ -181,33 +187,27 @@ export function ConnectionsNewRouteScreen({ }, [connectAndClose, routePairingUrl, shouldAutoConnect]); return ( - + { + if (showScanner) { + closeScanner(); + } else { + void openScanner(); + } + }, + }, + ]} + > - {Platform.OS === "android" ? ( - navigation.goBack()} - actions={[ - { - accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code", - icon: showScanner ? "xmark" : "camera", - onPress: () => { - if (showScanner) { - closeScanner(); - } else { - void openScanner(); - } - }, - }, - ]} - /> - ) : ( + {Platform.OS !== "android" ? ( - )} + ) : null} : null} - { - void handleSubmit(); - }} - /> + + { + void handleSubmit(); + }} + /> + )} - + ); } diff --git a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx index bfab389a3a64..28bb53270434 100644 --- a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx @@ -1,9 +1,10 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useState } from "react"; -import { Platform, ScrollView, View } from "react-native"; +import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; diff --git a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx index a55bb8f859e6..ff21a6c31a90 100644 --- a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx +++ b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx @@ -1,12 +1,14 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import Constants from "expo-constants"; import * as Updates from "expo-updates"; import { useEffect, useState } from "react"; -import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native"; +import { ActivityIndicator, Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { SettingsScreen } from "../settings/components/SettingsScreen"; import { SettingsSection } from "../settings/components/SettingsSection"; import { formatStartupCrashReport, @@ -71,7 +73,7 @@ export function SettingsDiagnosticsRouteScreen() { }; return ( - + @@ -131,7 +133,7 @@ export function SettingsDiagnosticsRouteScreen() { - + ); } @@ -145,7 +147,7 @@ function EmptyState(props: { diff --git a/apps/mobile/src/features/files/MaterialFilesHeader.tsx b/apps/mobile/src/features/files/MaterialFilesHeader.tsx new file mode 100644 index 000000000000..2ce953569b68 --- /dev/null +++ b/apps/mobile/src/features/files/MaterialFilesHeader.tsx @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { SymbolView } from "../../components/AppSymbol"; + +/** Keep Files search in the same header row on compact and expanded layouts. */ +export function MaterialFilesHeader(props: { + readonly projectName: string; + readonly searchQuery: string; + readonly onSearchQueryChange: (query: string) => void; + readonly onRefresh: () => void; + readonly onBack?: () => void; + readonly leading?: ReactNode; +}) { + const insets = useSafeAreaInsets(); + const searchRef = useRef(null); + const [searchOpen, setSearchOpen] = useState(false); + const searching = searchOpen || props.searchQuery.length > 0; + const { onSearchQueryChange } = props; + const closeSearch = useCallback(() => { + onSearchQueryChange(""); + setSearchOpen(false); + Keyboard.dismiss(); + }, [onSearchQueryChange]); + + useEffect(() => { + if (!searching) return; + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + closeSearch(); + return true; + }); + return () => subscription.remove(); + }, [closeSearch, searching]); + + return ( + + {/* Keep the title/subtitle's natural height, including larger text, while searching. */} + + setSearchOpen(true), + }, + { + accessibilityLabel: "Refresh files", + icon: "arrow.clockwise", + onPress: props.onRefresh, + }, + ]} + /> + + {searching ? ( + + + + + + + {props.searchQuery.length > 0 ? ( + { + onSearchQueryChange(""); + searchRef.current?.focus(); + }} + > + + + ) : null} + + + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 82d69b28e841..761849725839 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -4,6 +4,7 @@ import type { ComponentType } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FlatList, + Platform, RefreshControl, ScrollView, Text as NativeText, @@ -73,6 +74,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { {isAndroid ? ( <> - - - } + searchQuery={searchQuery} + onSearchQueryChange={setSearchQuery} + onRefresh={entriesQuery.refresh} + onBack={handleReturnToThread} /> - - + } ) : ( <> @@ -548,26 +521,28 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { )} )} - - + + + + ); - return materialYouStyleLayoutActive ? ( - + return Platform.OS === "android" ? ( + {content} ) : ( @@ -933,6 +908,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { } + hideBottomBorder onBack={handleBack} trailing={ <> @@ -942,6 +919,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator" } icon="sidebar.right" + selected={panes.auxiliaryPaneVisible} onPress={toggleAuxiliaryPane} /> ) : null} @@ -1007,25 +985,27 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ))} - fileQuery.refresh()} - /> + + fileQuery.refresh()} + /> + setFullScreenPreview(null)} diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index 4bb847dc546a..f62ba6b7abba 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -1,5 +1,6 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; +import { MaterialScreenContent } from "../../components/MaterialScreenContent"; import { useCallback, useMemo, useState, type ComponentProps } from "react"; import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native"; import { @@ -11,6 +12,7 @@ import { } from "react-native-screens"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { MaterialFilesHeader } from "./MaterialFilesHeader"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -149,59 +151,68 @@ export function ThreadFileNavigatorPane(props: { } return ( - - - - - Files - - {props.projectName} - + + + {Platform.OS === "android" ? ( + + ) : ( + + + Files + + {props.projectName} + + + + + - + )} + {Platform.OS !== "android" ? ( + - - - - - - - - - + + + ) : null} - {fileTree} + {fileTree} ); } diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx new file mode 100644 index 000000000000..012feb5ff68b --- /dev/null +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -0,0 +1,44 @@ +import { useCallback, useRef, useState, type ComponentProps } from "react"; +import { View, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton"; +import type { AndroidHomeFabLayout as SharedAndroidHomeFabLayout } from "./AndroidHomeFab.shared"; +import { useWorkspaceState } from "../../state/workspace"; +import { MaterialFabScrollContext } from "./MaterialFabScrollContext"; +import { updateMaterialFabScroll } from "./material-fab-scroll"; + +export function AndroidHomeFabLayout(props: ComponentProps) { + const insets = useSafeAreaInsets(); + const { state } = useWorkspaceState(); + const [expanded, setExpanded] = useState(true); + const scrollState = useRef({ anchor: 0, expanded: true }); + const onScroll = useCallback((event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + const next = updateMaterialFabScroll( + scrollState.current, + contentOffset.y, + contentSize.height - layoutMeasurement.height, + ); + if (next.expanded !== scrollState.current.expanded) setExpanded(next.expanded); + scrollState.current = next; + }, []); + + return ( + + {props.children} + {state.hasConnections ? ( + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx b/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx new file mode 100644 index 000000000000..7962371c1e15 --- /dev/null +++ b/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +/** Other platforms render the list without Android's floating action button. */ +export function AndroidHomeFabLayout(props: { + readonly onStartNewTask: () => void; + readonly children: ReactNode; + readonly sidebar?: boolean; +}) { + return <>{props.children}; +} diff --git a/apps/mobile/src/features/home/AndroidHomeFab.tsx b/apps/mobile/src/features/home/AndroidHomeFab.tsx index 6957a6dab043..89bbb2a26a95 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.tsx @@ -1,48 +1 @@ -import type { ReactNode } from "react"; -import { Platform, Pressable, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; - -import { SymbolView } from "../../components/AppSymbol"; - -/** - * Android-only wrapper that overlays a bottom-right new-task FAB on a thread - * list. Other platforms render children unchanged. - */ -export function AndroidHomeFabLayout(props: { - readonly onStartNewTask: () => void; - readonly children: ReactNode; -}) { - if (Platform.OS !== "android") { - return <>{props.children}; - } - - return ; -} - -function AndroidHomeFab(props: { - readonly onStartNewTask: () => void; - readonly children: ReactNode; -}) { - const insets = useSafeAreaInsets(); - return ( - - {props.children} - - - - - ); -} +export { AndroidHomeFabLayout } from "./AndroidHomeFab.shared"; diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index f67c2bff0f68..bf4c28aa5474 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,18 +1,11 @@ -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; -import Constants from "expo-constants"; + import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useCallback, useMemo, useRef } from "react"; -import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; +import { Platform } from "react-native"; import type { SearchBarCommands } from "react-native-screens"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { ControlPillMenu } from "../../components/ControlPill"; -import { SymbolView } from "../../components/AppSymbol"; -import { T3Wordmark } from "../../components/T3Wordmark"; -import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; -import { resolveMobileStageLabel } from "../../lib/mobileBranding"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; @@ -22,7 +15,7 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import type { HomeProjectSortOrder } from "./homeThreadList"; -import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; +import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar"; import { buildHomeListFilterMenu, type HomeListFilterMenuEnvironment, @@ -67,9 +60,6 @@ function checkedMenuState(checked: boolean) { } function AndroidHomeHeader(props: HomeHeaderProps) { - const { materialYouStyleLayoutActive } = useAppearancePreferences(); - const insets = useSafeAreaInsets(); - const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); // Thread List v2 lays the list out in fixed creation order, so the // sort/group filter controls would be silently ignored — hide them and // key the "customized" icon state off the environment filter alone. @@ -200,120 +190,15 @@ function AndroidHomeHeader(props: HomeHeaderProps) { return ( <> - - - - {/* Brand slot doubles as the connection status surface: while an - environment reconnects, the lockup fades to a status label in - place (no layout shift in the list below). */} - - {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} - - - Code - - - - {stageLabel} - - - - } - /> - - - - - - - {/* Built identically to the filter button so the two circles - match exactly (ControlPill sizes via Tailwind classes and - resolves to a different box). */} - - - - - - - - - {props.searchQuery.length > 0 ? ( - props.onSearchQueryChange("")} - > - - - ) : null} - - - + ); } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index b2da5af8ed26..00060668ba5d 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -11,7 +11,11 @@ import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; -import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; +import { + AndroidWorkspaceSidebarButton, + WorkspaceSidebarToolbar, +} from "../layout/workspace-sidebar-toolbar"; +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; @@ -27,7 +31,7 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle export function HomeRouteScreen() { const { width: windowWidth } = useWindowDimensions(); - const { layout } = useAdaptiveWorkspaceLayout(); + const { layout, panes } = useAdaptiveWorkspaceLayout(); const projects = useProjects(); const threads = useThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); @@ -126,8 +130,24 @@ export function HomeRouteScreen() { /> } /> + {Platform.OS === "android" ? ( + } /> + ) : null} navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onAddConnection={ + Platform.OS === "android" && !catalogState.hasConnections + ? () => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentNew" }, + }) + : undefined + } + onStartNewTask={ + Platform.OS === "android" && panes.primarySidebarVisible + ? undefined + : () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }) + } /> ); diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index a0d7bf7433e5..80642147052f 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -30,13 +30,13 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { cn } from "../../lib/cn"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; +import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { usePendingThreadOrder } from "../../state/thread-order"; @@ -83,6 +83,7 @@ import { type HomeProjectSortOrder, } from "./homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; +import { useMaterialFabScroll } from "./MaterialFabScrollContext"; /* ─── Types ──────────────────────────────────────────────────────────── */ @@ -219,7 +220,6 @@ function HomeTopContentSpacer() { /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { - const { materialYouStyleLayoutActive } = useAppearancePreferences(); const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap >(() => new Map()); @@ -315,7 +315,9 @@ export function HomeScreen(props: HomeScreenProps) { const handleScrollBeginDrag = useCallback(() => { openSwipeableRef.current?.close(); }, []); + const onMaterialFabScroll = useMaterialFabScroll(); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ + onScroll: onMaterialFabScroll, onScrollBeginDrag: handleScrollBeginDrag, }); @@ -1104,11 +1106,11 @@ export function HomeScreen(props: HomeScreenProps) { if (!hasAnyThreads) { return ( - + + ) : undefined + } variant="plain" /> {emptyState.loading ? ( - + ) : null} @@ -1142,41 +1155,72 @@ export function HomeScreen(props: HomeScreenProps) { const listEmpty = !hasResults ? ( hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( - + ) : selectedProjectScope !== null ? ( ) : selectedEnvironmentLabel ? ( ) : ( - + ) ) : null; // Use the v2 project scope for its empty state. Snoozed threads need no // special empty state: their shelf header is a list row even while collapsed. const v2ListEmpty = hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( - + ) : v2ScopedProjectGroup !== null ? ( ) : ( listEmpty ); + if ( + Platform.OS === "android" && + (threadListV2Enabled ? threadListV2Items.length === 0 : listLayout.items.length === 0) + ) { + return ( + + + {threadListV2Enabled ? v2ListEmpty : listEmpty} + + + ); + } + if (threadListV2Enabled) { return ( - + @@ -1226,10 +1270,10 @@ export function HomeScreen(props: HomeScreenProps) { } return ( - + ) => void) | undefined +>(undefined); + +export function useMaterialFabScroll() { + return useContext(MaterialFabScrollContext); +} diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx new file mode 100644 index 000000000000..397e6984f3af --- /dev/null +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react"; +import { + BackHandler, + Keyboard, + Pressable, + TextInput, + View, + type LayoutChangeEvent, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { MenuAction } from "@react-native-menu/menu"; + +import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader"; +import { CompactBrandTitle } from "../../components/CompactBrandTitle"; +import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; +import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { SymbolView } from "../../components/AppSymbol"; +import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; +import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; +import { useWorkspaceState } from "../../state/workspace"; +import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight"; + +/** One toolbar height for the compact list and expanded sidebar, including search. */ +export function MaterialThreadListToolbar(props: { + readonly searchQuery: string; + readonly onSearchQueryChange: (query: string) => void; + readonly filterActions: MenuAction[]; + readonly filterCustomized: boolean; + readonly onFilterAction: NonNullable["onPressAction"]>; + readonly onOpenSettings: () => void; + readonly onOpenEnvironments: () => void; + readonly sidebar?: boolean; + readonly onLayout?: (event: LayoutChangeEvent) => void; + readonly onRequestVisibility?: () => void; +}) { + const insets = useSafeAreaInsets(); + const toolbarHeight = useMaterialToolbarHeight(); + const { state } = useWorkspaceState(); + const { onRequestVisibility, onSearchQueryChange } = props; + const searchRef = useRef(null); + const [searchOpen, setSearchOpen] = useState(false); + const searching = searchOpen || props.searchQuery.length > 0; + const openSearch = useCallback(() => { + onRequestVisibility?.(); + setSearchOpen(true); + searchRef.current?.focus(); + return true; + }, [onRequestVisibility]); + useHardwareKeyboardCommand("focusSearch", openSearch); + + const closeSearch = useCallback(() => { + onSearchQueryChange(""); + setSearchOpen(false); + Keyboard.dismiss(); + }, [onSearchQueryChange]); + + useEffect(() => { + if (!searching) return; + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + closeSearch(); + return true; + }); + return () => subscription.remove(); + }, [closeSearch, searching]); + + const filterIcon = props.filterCustomized + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle"; + const searchField = ( + + + + {props.searchQuery.length > 0 ? ( + { + props.onSearchQueryChange(""); + searchRef.current?.focus(); + }} + > + + + ) : null} + + ); + + return ( + <> + + + {searching ? ( + <> + + {searchField} + + ) : ( + <> + {/* Match the visible inset of the trailing 48dp icon button. */} + + } + /> + + + + + )} + + + {/* Sit 8dp above the 56dp extended New thread FAB. */} + {state.hasConnections ? ( + + + {(open) => ( + + )} + + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/home/material-fab-scroll.test.ts b/apps/mobile/src/features/home/material-fab-scroll.test.ts new file mode 100644 index 000000000000..c68613ed2999 --- /dev/null +++ b/apps/mobile/src/features/home/material-fab-scroll.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { updateMaterialFabScroll, type MaterialFabScrollState } from "./material-fab-scroll"; + +describe("Material FAB scroll direction", () => { + const initial: MaterialFabScrollState = { anchor: 0, expanded: true }; + + it("shrinks scrolling down and expands scrolling up without returning to the top", () => { + const collapsed = updateMaterialFabScroll(initial, 80, 500); + expect(collapsed.expanded).toBe(false); + const lower = updateMaterialFabScroll(collapsed, 180, 500); + expect(lower.expanded).toBe(false); + expect(updateMaterialFabScroll(lower, 160, 500).expanded).toBe(true); + }); + + it("accumulates travel but ignores jitter around a direction change", () => { + let state = updateMaterialFabScroll(initial, 10, 500); + expect(state.expanded).toBe(true); + state = updateMaterialFabScroll(state, 14, 500); + expect(state.expanded).toBe(false); + state = updateMaterialFabScroll(state, 100, 500); + state = updateMaterialFabScroll(state, 95, 500); + state = updateMaterialFabScroll(state, 98, 500); + expect(state.expanded).toBe(false); + expect(updateMaterialFabScroll(state, 88, 500).expanded).toBe(true); + }); + + it("does not expand from bottom bounce or collapse from top bounce", () => { + const bottom = updateMaterialFabScroll(initial, 500, 500); + const bounce = updateMaterialFabScroll(bottom, 560, 500); + expect(updateMaterialFabScroll(bounce, 500, 500).expanded).toBe(false); + expect(updateMaterialFabScroll(initial, -50, 500)).toEqual(initial); + }); + + it("expands near the top and when filtering leaves a non-scrollable list", () => { + const collapsed = updateMaterialFabScroll(initial, 80, 500); + expect(updateMaterialFabScroll(collapsed, 5, 500).expanded).toBe(true); + expect(updateMaterialFabScroll(collapsed, 80, -20)).toEqual(initial); + }); +}); diff --git a/apps/mobile/src/features/home/material-fab-scroll.ts b/apps/mobile/src/features/home/material-fab-scroll.ts new file mode 100644 index 000000000000..b89946803982 --- /dev/null +++ b/apps/mobile/src/features/home/material-fab-scroll.ts @@ -0,0 +1,17 @@ +export interface MaterialFabScrollState { + readonly anchor: number; + readonly expanded: boolean; +} + +/** Ignore small direction changes and overscroll; always show the label at the top. */ +export function updateMaterialFabScroll( + state: MaterialFabScrollState, + offset: number, + maxOffset: number, +): MaterialFabScrollState { + const y = Math.max(0, Math.min(offset, Math.max(0, maxOffset))); + if (y <= 8) return { anchor: y, expanded: true }; + const anchor = state.expanded ? Math.min(state.anchor, y) : Math.max(state.anchor, y); + if (Math.abs(y - anchor) >= 12) return { anchor: y, expanded: !state.expanded }; + return { anchor, expanded: state.expanded }; +} diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 185a3b157fc2..834301b80894 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -22,7 +22,7 @@ import { useState, type ReactNode, } from "react"; -import { useWindowDimensions, View } from "react-native"; +import { Platform, useWindowDimensions, View } from "react-native"; import Animated, { useAnimatedStyle, useDerivedValue, @@ -56,7 +56,6 @@ import { } from "../keyboard/hardwareKeyboardCommands"; import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { HomeListOptionsProvider } from "../home/home-list-options"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; @@ -234,7 +233,6 @@ function AdaptiveWorkspaceLayoutContent( }, ) { const projectGroupingMode = props.projectGroupingMode; - const { materialYouStyleLayoutActive } = useAppearancePreferences(); const { width, height } = useWindowDimensions(); const pathname = props.pathname; const navigation = useNavigation(); @@ -582,7 +580,7 @@ function AdaptiveWorkspaceLayoutContent( style={sidebarAnimatedStyle} > - + void }) { - const { materialYouStyleLayoutActive } = useAppearancePreferences(); +export function WorkspaceEmptyDetail(props: { + readonly onStartNewTask?: () => void; + readonly onAddConnection?: () => void; +}) { return ( - - - Select a thread - - Choose a thread from the sidebar or start a new task. - - {props.onStartNewTask ? ( - - New Task - - ) : null} - + {props.onAddConnection ? ( + + + } + /> + + ) : ( + + + Select a thread + + {Platform.OS === "android" + ? "Choose a thread from the sidebar or start a new thread." + : "Choose a thread from the sidebar or start a new task."} + + {props.onStartNewTask ? ( + Platform.OS === "android" ? ( + + ) : ( + + New Task + + ) + ) : null} + + )} ); } diff --git a/apps/mobile/src/features/layout/workspace-pane-divider.tsx b/apps/mobile/src/features/layout/workspace-pane-divider.tsx index cf640f19106a..01d9646e9c8b 100644 --- a/apps/mobile/src/features/layout/workspace-pane-divider.tsx +++ b/apps/mobile/src/features/layout/workspace-pane-divider.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useRef, useState } from "react"; -import { Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native"; +import { Platform, Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { runOnJS } from "react-native-reanimated"; import { cn } from "../../lib/cn"; @@ -79,6 +79,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { className={cn( "h-full self-center bg-border opacity-70", dragging ? "w-0.5 bg-primary opacity-100" : "w-px", + Platform.OS === "android" && !dragging && "opacity-0", )} style={[styles.line, dragging && styles.activeLine]} /> diff --git a/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx index d07a003361ee..26677327575a 100644 --- a/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx +++ b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx @@ -2,8 +2,26 @@ import { NativeHeaderToolbar } from "../../native/StackHeader"; import type { ReactNode } from "react"; import { Platform } from "react-native"; +import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader"; + import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; +export function AndroidWorkspaceSidebarButton() { + const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + if (Platform.OS !== "android" || !layout.usesSplitView) return null; + + return ( + + ); +} + export function WorkspaceSidebarToolbar( props: { readonly children?: ReactNode; diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 5724abb138c8..4da58b3309ef 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,3 +1,7 @@ +import { MaterialListRow } from "../../components/MaterialListRow"; +import { SettingsScreen } from "../settings/components/SettingsScreen"; +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; +import { MaterialButton } from "../../components/MaterialButton"; import { addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, @@ -41,14 +45,13 @@ import { import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; +import { Platform, ActivityIndicator, Alert, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as Arr from "effect/Array"; import * as Cause from "effect/Cause"; import * as Order from "effect/Order"; import { AsyncResult } from "effect/unstable/reactivity"; import { cn } from "../../lib/cn"; - import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; import { projectEnvironment } from "../../state/projects"; @@ -123,13 +126,19 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote function SectionTitle(props: { readonly children: string }) { return ( - + {props.children} ); } -function AddProjectShell(props: { readonly children: ReactNode }) { +function AddProjectShell(props: { readonly children: ReactNode; readonly title: string }) { const insets = useSafeAreaInsets(); return ( @@ -138,25 +147,35 @@ function AddProjectShell(props: { readonly children: ReactNode }) { // scroll-view frame correction mistakes this full-height wrapper for a // "header" sibling, coercing the ScrollView to zero height (blank sheet // as soon as the sheet re-lays-out, e.g. when the keyboard opens). - + {props.children} - + ); } function ListSection(props: { readonly children: ReactNode }) { - return {props.children}; + return ( + + {props.children} + + ); } function ListRow(props: { @@ -169,6 +188,20 @@ function ListRow(props: { readonly right?: ReactNode; readonly onPress?: () => void; }) { + if (Platform.OS === "android") { + return ( + + ); + } return ( ) : null} @@ -218,6 +251,7 @@ function PrimaryActionButton(props: { readonly loading?: boolean; readonly onPress: () => void; }) { + if (Platform.OS === "android") return ; return ( + ) : ( - + ); if (!props.ready) { @@ -482,7 +525,7 @@ export function AddProjectSourceScreen() { ); return ( - + {selectedEnvironment === null ? : null} {environmentOptions.length > 1 ? ( @@ -495,7 +538,7 @@ export function AddProjectSourceScreen() { title={environment.label} subtitle={ canCreateProjectInEnvironment(environment.connectionState) - ? environment.environmentId + ? undefined : connectionStatusText({ phase: environment.connectionState, error: environment.connectionError, @@ -505,7 +548,7 @@ export function AddProjectSourceScreen() { icon={ } @@ -516,8 +559,8 @@ export function AddProjectSourceScreen() { environment.environmentId === selectedEnvironment?.environmentId ? ( ) : null @@ -538,8 +581,8 @@ export function AddProjectSourceScreen() { icon={ } @@ -570,7 +613,7 @@ export function AddProjectSourceScreen() { )} {discoveryState.isPending ? ( - + ) : null} ) : null} @@ -723,7 +766,7 @@ export function AddProjectRepositoryScreen(props: { }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); return ( - + {error ? : null} {environment ? ( <> @@ -800,7 +843,7 @@ function FolderBrowser(props: { {browseState.isPending && browseState.data === null ? ( - + ) : null} {browsePath.canBrowseUp ? ( @@ -809,8 +852,8 @@ function FolderBrowser(props: { icon={ } @@ -832,8 +875,8 @@ function FolderBrowser(props: { icon={ } @@ -882,7 +925,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st }, [createProject, environment, isBrowseNavigating, isSubmitting, pathInput]); return ( - + {error ? : null} {environment ? ( <> @@ -1025,7 +1068,7 @@ export function AddProjectDestinationScreen(props: { ]); return ( - + {error ? : null} {repositoryTitle ? ( diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 22fa1bc5d506..0035f91e2d18 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -35,6 +35,8 @@ import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ControlPillMenu } from "../../components/ControlPill"; +import { MaterialScreenContent } from "../../components/MaterialScreenContent"; +import { cn } from "../../lib/cn"; import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -54,7 +56,10 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useThreadSelection } from "../../state/use-thread-selection"; import { vcsEnvironment } from "../../state/vcs"; -import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; +import { + AndroidWorkspaceSidebarButton, + WorkspaceSidebarToolbar, +} from "../layout/workspace-sidebar-toolbar"; import { ThreadGitMenu } from "../threads/ThreadGitControls"; import { useReviewCacheForThread } from "./reviewState"; import { @@ -81,7 +86,12 @@ const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { return ( - + Partial diff {props.notice} @@ -103,7 +113,7 @@ function ReviewSelectionActionBar(props: { {props.title} @@ -143,7 +153,7 @@ function ReviewSelectionActionBar(props: { @@ -174,9 +184,14 @@ const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: { accessibilityRole="button" accessibilityState={{ selected }} className={ - selected - ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2" - : "mt-1 min-h-12 justify-center rounded-xl px-3 py-2 active:bg-subtle" + Platform.OS === "android" + ? cn( + "mt-1 min-h-12 justify-center rounded-[20px] px-3 py-2 active:bg-subtle", + selected && "bg-thread-selected", + ) + : selected + ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2" + : "mt-1 min-h-12 justify-center rounded-xl px-3 py-2 active:bg-subtle" } onPress={handlePress} > @@ -323,16 +338,28 @@ function ReviewFileNavigator({ } return ( - - - - Changed files - - {files.length} {files.length === 1 ? "file" : "files"} - + + {Platform.OS === "android" ? ( + + ) : ( + + + Changed files + + {files.length} {files.length === 1 ? "file" : "files"} + + - - {fileList} + )} + {fileList} ); } @@ -632,23 +659,21 @@ export function ReviewSheet(props: ReviewSheetProps) { parsedDiff.kind === "files" && NativeReviewDiffView !== null; useRegisterWorkspaceInspector(showChangedFilesPane ? renderInspector : undefined); - // Raw fallback renders the patch inline with no inspector content, so the - // pane toggle would open an empty column — hide it in exactly that case. - const showChangedFilesToggle = - panes.supportsAuxiliaryPane && - !( - !showConnectionNotice && - selectedSection !== null && - parsedDiff.kind === "files" && - NativeReviewDiffView === null - ); + // A toggle needs registered content; loading, errors and raw patches have no navigator pane. + const showChangedFilesToggle = panes.supportsAuxiliaryPane && showChangedFilesPane; const listHeader = useMemo(() => { const children: ReactElement[] = []; if (error) { children.push( - + Review unavailable {error} , @@ -699,21 +724,35 @@ export function ReviewSheet(props: ReviewSheetProps) { {isAndroid ? ( } + hideBottomBorder subtitle={androidHeaderSubtitle || "Select a diff"} onBack={handleReturnToThread} trailing={ - showSectionToolbar ? ( - + <> + {showChangedFilesToggle ? ( - - ) : null + ) : null} + {showSectionToolbar ? ( + + + + ) : null} + } /> ) : null} @@ -805,141 +844,187 @@ export function ReviewSheet(props: ReviewSheetProps) { ) : null} - - {showConnectionNotice ? ( - - + + {showConnectionNotice ? ( + + - - ) : selectedSection && parsedDiff.kind === "files" && NativeReviewDiffView ? ( - + resourceName="review" + onRetry={handleRetryEnvironment} + /> + + ) : selectedSection && parsedDiff.kind === "files" && NativeReviewDiffView ? ( - {listHeader} - - void handlePullToRefresh()} - style={StyleSheet.absoluteFill} - appearanceScheme={selectedTheme} - collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson} - collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson} - contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`} - contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} - nativeViewRef={nativeReviewDiffViewRef} - rowHeight={nativeReviewDiffStyle.rowHeight} - rowsJson={nativeBridge.rowsJson} - selectedRowIdsJson={nativeBridge.selectedRowIdsJson} - styleJson={nativeBridge.styleJson} - themeJson={nativeBridge.themeJson} - tokensPatchJson={nativeBridge.tokensPatchJson} - tokensResetKey={nativeBridge.tokensResetKey} - viewedFileIdsJson={nativeBridge.viewedFileIdsJson} - onDebug={handleNativeDebug} - onPressLine={commentSelection.onPressLine} - onVisibleFileChange={handleVisibleFileChange} - onToggleComment={nativeBridge.onToggleComment} - onToggleFile={handleNativeToggleFile} - onToggleViewedFile={handleNativeToggleViewedFile} - /> + + {listHeader} + + void handlePullToRefresh()} + style={StyleSheet.absoluteFill} + appearanceScheme={selectedTheme} + collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson} + collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson} + contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`} + contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} + nativeViewRef={nativeReviewDiffViewRef} + rowHeight={nativeReviewDiffStyle.rowHeight} + rowsJson={nativeBridge.rowsJson} + selectedRowIdsJson={nativeBridge.selectedRowIdsJson} + styleJson={nativeBridge.styleJson} + themeJson={nativeBridge.themeJson} + tokensPatchJson={nativeBridge.tokensPatchJson} + tokensResetKey={nativeBridge.tokensResetKey} + viewedFileIdsJson={nativeBridge.viewedFileIdsJson} + onDebug={handleNativeDebug} + onPressLine={commentSelection.onPressLine} + onVisibleFileChange={handleVisibleFileChange} + onToggleComment={nativeBridge.onToggleComment} + onToggleFile={handleNativeToggleFile} + onToggleViewedFile={handleNativeToggleViewedFile} + /> + - - ) : ( - void handlePullToRefresh()} - /> - } - > - {listHeader} - {!selectedSection ? ( - - No review diffs - - This thread has no ready turn diffs and the worktree diff is empty. - - - ) : selectedSection.isLoading && selectedSection.diff === null ? ( - - - Loading diff… - - ) : parsedDiff.kind === "empty" ? ( - - No changes - - {selectedSection.subtitle ?? "This diff is empty."} - - - ) : parsedDiff.kind === "raw" ? ( - - - {parsedDiff.reason} - - - - {parsedDiff.text} + ) : ( + void handlePullToRefresh()} + /> + } + > + {listHeader} + {!selectedSection ? ( + + No review diffs + + This thread has no ready turn diffs and the worktree diff is empty. - - - ) : parsedDiff.kind === "files" ? ( - // The native diff surface could not be resolved on this binary; - // degrade to the raw patch instead of crashing the app. - - - Native diff view unavailable. Showing the raw patch. - - - - {selectedSection?.diff ?? ""} + + ) : selectedSection.isLoading && selectedSection.diff === null ? ( + + + Loading diff… + + ) : parsedDiff.kind === "empty" ? ( + + No changes + + {selectedSection.subtitle ?? "This diff is empty."} - - - ) : null} - - )} - - + + ) : parsedDiff.kind === "raw" ? ( + + + {parsedDiff.reason} + + + + {parsedDiff.text} + + + + ) : parsedDiff.kind === "files" ? ( + // The native diff surface could not be resolved on this binary; + // degrade to the raw patch instead of crashing the app. + + + Native diff view unavailable. Showing the raw patch. + + + + {selectedSection?.diff ?? ""} + + + + ) : null} + + )} + + + ); } diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts index 7387adb567e7..21c86a2beab4 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts @@ -255,6 +255,31 @@ describe("createNativeReviewDiffTheme", () => { } }); + it.each(["light", "dark"] as const)( + "preserves Material You RGBA hex channels and composites alpha in %s", + (appearance) => { + const variables = { + ...appTheme("material-you", appearance), + "--color-screen": "#101214FF", + "--color-sheet": "#20222480", + "--color-md-code-text": "#E3E2E6FF", + "--color-foreground-muted": "#C7C5D080", + "--color-border": "#44464F80", + "--color-primary": "#A8C7FAFF", + }; + const theme = createNativeReviewDiffTheme(appearance, "material-you", variables); + expect(theme.background).toBe("#181a1c"); + expect(theme.headerBackground).toBe(theme.background); + expect(theme.text).toBe("#e3e2e6"); + expect(theme.mutedText).toBe("#707076"); + expect(theme.border).toBe("#2e3036"); + expect(theme.hunkText).toBe("#a8c7fa"); + for (const color of Object.values(theme)) { + expect(color).toMatch(/^#[\da-f]{6}$/i); + } + }, + ); + it("uses the selected app palette for native code surfaces", () => { const standard = createNativeReviewDiffTheme("dark", "t3-code", appTheme("t3-code", "dark")); const iris = createNativeReviewDiffTheme("dark", "iris", appTheme("iris", "dark")); diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index 6cc56e8ceab9..b35d561a249d 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -19,7 +19,7 @@ import type { ReviewInlineComment } from "./reviewCommentSelection"; const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4; const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; -const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i; +const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})?$/i; const NATIVE_RGBA_COLOR = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/; @@ -44,15 +44,19 @@ export function buildNativeReviewSnippetRows( function opaqueNativeHexColor(color: string, background: string): string { const hex = NATIVE_HEX_COLOR.exec(color); - if (hex) return color; + if (hex && !hex[4]) return color; const rgba = NATIVE_RGBA_COLOR.exec(color); const backgroundHex = NATIVE_HEX_COLOR.exec(background); - if (!rgba || !backgroundHex) return background; + if ((!hex && !rgba) || !backgroundHex) return background; - const alpha = rgba[4] === undefined ? 1 : Math.min(1, Math.max(0, Number(rgba[4]))); + const alpha = hex + ? Number.parseInt(hex[4] ?? "ff", 16) / 255 + : rgba?.[4] === undefined + ? 1 + : Math.min(1, Math.max(0, Number(rgba[4]))); const channels = [1, 2, 3].map((index) => { - const foreground = Number(rgba[index]); + const foreground = hex ? Number.parseInt(hex[index] ?? "0", 16) : Number(rgba?.[index]); const behind = Number.parseInt(backgroundHex[index] ?? "0", 16); return Math.round(foreground * alpha + behind * (1 - alpha)); }); @@ -169,7 +173,11 @@ export function createNativeReviewDiffTheme( // Swift expects #RRGGBB/#RRGGBBAA while Android expects #RRGGBB/#AARRGGBB. // Flatten translucent app tokens onto the code surface so both native // implementations receive the one unambiguous shared format. - const background = opaqueNativeHexColor(appTheme["--color-sheet"], appTheme["--color-screen"]); + const screen = opaqueNativeHexColor( + appTheme["--color-screen"], + scheme === "dark" ? "#000000" : "#ffffff", + ); + const background = opaqueNativeHexColor(appTheme["--color-sheet"], screen); const nativeColor = (color: string) => opaqueNativeHexColor(color, background); if (scheme === "dark") { diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx index a97193d6b6a5..4fbfb98bfdd2 100644 --- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -1,26 +1,17 @@ -import { useNavigation } from "@react-navigation/native"; -import { Platform, ScrollView, View } from "react-native"; +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { SettingsScreen } from "./components/SettingsScreen"; import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection"; export function SettingsAppearanceRouteScreen() { - const navigation = useNavigation(); const insets = useSafeAreaInsets(); return ( - - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} /> - - ) : null} + - + ); } diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx index bf66574717eb..5c36b16cac20 100644 --- a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -1,8 +1,9 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { type EnvironmentMachineKind, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; import { useMemo } from "react"; -import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; +import { ActivityIndicator, Alert, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; @@ -16,6 +17,7 @@ import { import { useServerConfigs } from "../../state/entities"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsSection } from "./components/SettingsSection"; +import { SettingsScreen } from "./components/SettingsScreen"; export function SettingsClientStorageRouteScreen() { const insets = useSafeAreaInsets(); @@ -71,7 +73,7 @@ export function SettingsClientStorageRouteScreen() { }; return ( - + @@ -123,7 +125,7 @@ export function SettingsClientStorageRouteScreen() { @@ -146,16 +148,14 @@ export function SettingsClientStorageRouteScreen() { {summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"} - {isClearing ? ( - - ) : null} + {isClearing ? : null} @@ -169,7 +169,7 @@ export function SettingsClientStorageRouteScreen() { ) : null} - + ); } diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 653107d22bb3..1ca43b5a73cf 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -1,13 +1,14 @@ -import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; +import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useState } from "react"; -import { Platform, ScrollView, View } from "react-native"; +import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { SettingsScreen } from "./components/SettingsScreen"; import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings"; @@ -79,28 +80,21 @@ export function SettingsEnvironmentsRouteScreen() { ); return ( - - {Platform.OS === "android" ? ( - <> - {/* Android renders its own in-screen header instead of the native bar. */} - - navigation.goBack()} - actions={[ - { - accessibilityLabel: "Add environment", - icon: "plus", - onPress: () => - navigation.navigate("SettingsSheet", { - screen: "SettingsContent", - params: { screen: "SettingsEnvironmentNew" }, - }), - }, - ]} - /> - - ) : ( + + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentNew" }, + }), + }, + ]} + > + {Platform.OS !== "android" ? ( - )} + ) : null} - + ); } diff --git a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx index 16cb39e4b8cb..efeca40e0ff1 100644 --- a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx @@ -1,3 +1,4 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { LegendList } from "@legendapp/list/react-native"; import { type StaticScreenProps, useNavigation } from "@react-navigation/native"; import { @@ -8,18 +9,19 @@ import { type ThirdPartyLicenseEntry, } from "@t3tools/shared/thirdPartyLicenses"; import { useCallback, useMemo, useState } from "react"; -import { Linking, Platform, Pressable, ScrollView, View } from "react-native"; +import { Linking, Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { SettingsScreen } from "./components/SettingsScreen"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { createNativeMailSearchToolbarItem, NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; + import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses"; function useMobileThirdPartyLicenses() { @@ -97,24 +99,18 @@ export function SettingsOpenSourceLicensesRouteScreen() { if (!manifest) { return ( - - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} /> - - ) : null} + License notices are unavailable in this build. - + ); } return ( - + {Platform.OS === "ios" ? ( ) : null} - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} /> - - ) : null} + - + ); } type LicenseDetailProps = StaticScreenProps<{ readonly entryKey: string }>; export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailProps) { - const navigation = useNavigation(); const insets = useSafeAreaInsets(); const manifest = useMobileThirdPartyLicenses(); const entry = manifest @@ -212,30 +202,18 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro if (!entry) { return ( - - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} /> - - ) : null} + This license notice is unavailable. - + ); } return ( - - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} /> - - ) : null} + - + ); } diff --git a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx index 951168fefcf6..5eb815098aaa 100644 --- a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx @@ -1,14 +1,13 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { useNavigation } from "@react-navigation/native"; import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; -import { Platform, Pressable, ScrollView, View } from "react-native"; +import { Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { SettingsScreen } from "./components/SettingsScreen"; import { mobileProjectGroupingModePatch, resolveMobileProjectGroupingSettings, @@ -39,7 +38,6 @@ const GROUPING_OPTIONS: ReadonlyArray<{ ]; export function SettingsProjectGroupingRouteScreen() { - const navigation = useNavigation(); const insets = useSafeAreaInsets(); const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -49,13 +47,7 @@ export function SettingsProjectGroupingRouteScreen() { : null; return ( - - {Platform.OS === "android" ? ( - <> - - navigation.goBack()} /> - - ) : null} + - + ); } diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index dc4f1d2ee32b..d32dcb3e6828 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -1,3 +1,5 @@ +import { AutoSettleDaysField } from "./components/AutoSettleDaysField"; +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useAuth, useUser } from "@clerk/expo"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import Constants from "expo-constants"; @@ -8,7 +10,7 @@ import { SymbolView } from "../../components/AppSymbol"; import * as Effect from "effect/Effect"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native"; +import { Alert, Linking, Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { @@ -18,8 +20,7 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; -import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { AppText as Text } from "../../components/AppText"; import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; import { openAndroidLiveUpdateSettings, @@ -37,15 +38,12 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; +import { cn } from "../../lib/cn"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { useEnvironments } from "../../state/environments"; -import { - DEFAULT_SERVER_SETTINGS, - MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, - MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, -} from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { @@ -58,6 +56,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; +import { SettingsScreen } from "./components/SettingsScreen"; import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; @@ -79,17 +78,16 @@ function useDeviceRegistered(): boolean { export function SettingsRouteScreen() { const navigation = useNavigation(); + const content = hasCloudPublicConfig() ? ( + + ) : ( + + ); return ( <> - {Platform.OS === "android" ? ( - <> - {/* Android renders its own in-screen header instead of the native bar. */} - - navigation.goBack()} /> - - ) : ( + {Platform.OS !== "android" ? ( + ) : null} + {Platform.OS === "android" ? ( + {content} + ) : ( + content )} - {hasCloudPublicConfig() ? : } ); } @@ -134,6 +136,7 @@ function LocalSettingsRouteScreen() { icon="desktopcomputer" label="Environments" value={`${environmentCount}`} + valuePosition="trailing" target="SettingsEnvironments" /> @@ -516,6 +519,7 @@ function ConfiguredSettingsRouteScreen() { icon="desktopcomputer" label="Environments" value={`${environmentCount}`} + valuePosition="trailing" target="SettingsEnvironments" /> (null); - if (reference === null || referenceSettings === null) { return null; } const writeToAll = (patch: Partial) => { - for (const environment of syncTargets) { - void updateSettings({ environmentId: environment.environmentId, input: { patch } }); - } + setPendingWrites((count) => count + 1); + void Promise.allSettled( + syncTargets.map((environment) => + updateSettings({ environmentId: environment.environmentId, input: { patch } }), + ), + ).finally(() => setPendingWrites((count) => count - 1)); }; const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( @@ -647,21 +653,6 @@ function AutoSettleSettingsRows() { ); const afterDays = referenceSettings.sidebarAutoSettleAfterDays; - const commitDays = () => { - const draft = (daysDraft ?? "").trim(); - setDaysDraft(null); - // Whole-string check so "3.5" and "3days" are rejected instead of - // silently becoming 3 on every eligible sync target. - const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN; - if ( - Number.isInteger(parsed) && - parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && - parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && - parsed !== afterDays - ) { - writeToAll({ sidebarAutoSettleAfterDays: parsed }); - } - }; return ( <> @@ -674,28 +665,34 @@ function AutoSettleSettingsRows() { writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) } /> {afterDays !== null ? ( - - Days before auto-settle - + + + Inactive days + + writeToAll({ sidebarAutoSettleAfterDays: value })} /> ) : null} - {mismatches.length > 0 ? ( + {pendingWrites === 0 && mismatches.length > 0 ? ( Auto-settle defaults differ @@ -705,14 +702,7 @@ function AutoSettleSettingsRows() { { - for (const mismatch of mismatches) { - void updateSettings({ - environmentId: mismatch.environmentId, - input: { patch: autoSettlePatch }, - }); - } - }} + onPress={() => writeToAll(autoSettlePatch)} className="rounded-full bg-subtle px-4 py-2 active:opacity-70" > @@ -831,7 +821,7 @@ function AppSettingsSection() { diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index e6622e3269e9..5e0426daaa0b 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -51,9 +51,6 @@ interface AppearancePreferencesContextValue { readonly themeIds: MobileThemeIds; readonly themeMode: MobileThemeMode; readonly themeAppearance: MobileThemeAppearance; - readonly materialYouStyleLayoutEnabled: boolean; - readonly materialYouStyleLayoutActive: boolean; - readonly setMaterialYouStyleLayoutEnabled: (value: boolean) => void; readonly systemColorsAvailable: boolean; readonly systemColorsActive: boolean; readonly themeVariables: MobileThemeVariables; @@ -97,8 +94,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [resolvedThemeIds.dark, resolvedThemeIds.light], ); const themeId = themeIds[themeAppearance]; - const materialYouStyleLayoutEnabled = storedPreferences?.materialYouStyleLayoutEnabled ?? false; - const materialYouStyleLayoutActive = Platform.OS === "android" && materialYouStyleLayoutEnabled; const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable; const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes); useEffect(() => { @@ -120,7 +115,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN }, []); const themeVariablesByAppearance = useMemo(() => { const resolve = (appearance: MobileThemeAppearance) => { - const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance); + const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance, Platform.OS); return themeIds[appearance] === "material-you" && systemColorPalettes ? materialYouPaletteToMobileThemeVariables( systemColorPalettes[appearance], @@ -247,13 +242,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [runtimeState, syncThemeRuntime, updateThemePreferences], ); - const setMaterialYouStyleLayoutEnabled = useCallback( - (value: boolean) => { - updatePreferences({ materialYouStyleLayoutEnabled: value }); - }, - [updatePreferences], - ); - const setBaseFontSize = useCallback( (value: number) => { const current = appliedRuntimeStateRef.current ?? runtimeState; @@ -293,9 +281,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeAppearance, systemColorsAvailable: isSystemColorsAvailable, systemColorsActive, - materialYouStyleLayoutEnabled, - materialYouStyleLayoutActive, - setMaterialYouStyleLayoutEnabled, themeVariables, themeVariablesByAppearance, systemColorPalettes, @@ -315,9 +300,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeMode, themeAppearance, systemColorsActive, - materialYouStyleLayoutEnabled, - materialYouStyleLayoutActive, - setMaterialYouStyleLayoutEnabled, themeVariables, themeVariablesByAppearance, systemColorPalettes, diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.android.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.android.tsx new file mode 100644 index 000000000000..6588895f73a4 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.android.tsx @@ -0,0 +1,81 @@ +import { Host, Slider } from "@expo/ui/jetpack-compose"; +import { fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; +import * as Haptics from "expo-haptics"; +import { useRef, type ComponentProps } from "react"; +import { View } from "react-native"; + +import { AppText as Text } from "../../../../components/AppText"; +import { SymbolView } from "../../../../components/AppSymbol"; +import { useAppearancePreferences } from "../AppearancePreferencesProvider"; +import type { FontSizeSliderRow as SharedFontSizeSliderRow } from "./FontSizeSliderRow.shared"; + +export function FontSizeSliderRow(props: ComponentProps) { + const { + themeAppearance, + systemColorsActive, + themeVariables: colors, + } = useAppearancePreferences(); + const draft = useRef(props.value); + const commit = (value: number) => { + if (props.disabled) return; + const next = Math.min( + props.max, + Math.max(props.min, props.min + Math.round((value - props.min) / props.step) * props.step), + ); + if (next === props.value) return; + void Haptics.selectionAsync().catch(() => undefined); + props.onChange(next); + }; + return ( + + + + {props.label} + {props.valueLabel} + + { + if (nativeEvent.actionName === "increment") commit(props.value + props.step); + else if (nativeEvent.actionName === "decrement") commit(props.value - props.step); + }} + > + + + { + draft.current = value; + }} + onValueChangeFinished={() => commit(draft.current)} + /> + + + + + ); +} diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.shared.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.shared.tsx new file mode 100644 index 000000000000..98a17388485c --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.shared.tsx @@ -0,0 +1,211 @@ +import * as Haptics from "expo-haptics"; +import { SymbolView } from "../../../../components/AppSymbol"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { View, type AccessibilityActionEvent } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import type { ComponentProps } from "react"; + +import { AppText as Text } from "../../../../components/AppText"; + +type SymbolName = ComponentProps["name"]; + +const THUMB_SIZE = 26; +const TRACK_HEIGHT = 4; +const SNAP_ANIMATION = { duration: 120 } as const; + +function clampFraction(value: number): number { + "worklet"; + return Math.min(1, Math.max(0, value)); +} + +export function FontSizeSliderRow(props: { + readonly disabled?: boolean; + readonly icon: SymbolName; + readonly label: string; + readonly valueLabel: string; + readonly min: number; + readonly max: number; + readonly step: number; + readonly value: number; + readonly onChange: (value: number) => void; +}) { + const latest = useRef(props); + latest.current = props; + + const { min, max, step, value, disabled } = props; + const fraction = (value - min) / (max - min); + + const progress = useSharedValue(clampFraction(fraction)); + const trackWidth = useSharedValue(0); + const dragging = useSharedValue(false); + + useEffect(() => { + if (!dragging.value) { + progress.value = withTiming(clampFraction(fraction), SNAP_ANIMATION); + } + }, [dragging, fraction, progress]); + + const commit = useCallback((next: number) => { + const current = latest.current; + if (current.disabled || next === current.value) { + return; + } + Haptics.selectionAsync().catch(() => undefined); + current.onChange(next); + }, []); + + const gesture = useMemo(() => { + const snapValue = (raw: number): number => { + "worklet"; + const stepped = Math.round((raw - min) / step) * step + min; + return Math.min(max, Math.max(min, stepped)); + }; + const fractionAt = (x: number): number => { + "worklet"; + const usable = trackWidth.value - THUMB_SIZE; + if (usable <= 0) { + return 0; + } + return clampFraction((x - THUMB_SIZE / 2) / usable); + }; + const valueAtFraction = (f: number): number => { + "worklet"; + return snapValue(min + f * (max - min)); + }; + const fractionOfValue = (v: number): number => { + "worklet"; + return clampFraction((v - min) / (max - min)); + }; + + const pan = Gesture.Pan() + .enabled(!disabled) + .activeOffsetX([-8, 8]) + .failOffsetY([-12, 12]) + .onUpdate((event) => { + dragging.value = true; + const f = fractionAt(event.x); + progress.value = f; + }) + .onFinalize((_event, success) => { + if (!dragging.value) { + return; + } + dragging.value = false; + if (!success) { + progress.value = withTiming(fractionOfValue(value), SNAP_ANIMATION); + return; + } + const next = valueAtFraction(progress.value); + progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION); + runOnJS(commit)(next); + }); + + const tap = Gesture.Tap() + .enabled(!disabled) + .onEnd((event) => { + const next = valueAtFraction(fractionAt(event.x)); + progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION); + runOnJS(commit)(next); + }); + + return Gesture.Race(pan, tap); + }, [commit, disabled, dragging, max, min, progress, step, trackWidth, value]); + + const fillStyle = useAnimatedStyle(() => ({ + width: THUMB_SIZE / 2 + progress.value * Math.max(0, trackWidth.value - THUMB_SIZE), + })); + const thumbStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: progress.value * Math.max(0, trackWidth.value - THUMB_SIZE) }], + })); + + const handleAccessibilityAction = (event: AccessibilityActionEvent) => { + if (event.nativeEvent.actionName === "increment") { + commit(Math.min(max, value + step)); + } else if (event.nativeEvent.actionName === "decrement") { + commit(Math.max(min, value - step)); + } + }; + + return ( + + + + {props.label} + {props.valueLabel} + + + + + { + trackWidth.value = event.nativeEvent.layout.width; + }} + > + + + + + + + + + + ); +} diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx index 7f33f9226d10..76f18645c0bd 100644 --- a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx @@ -1,210 +1 @@ -import * as Haptics from "expo-haptics"; -import { SymbolView } from "../../../../components/AppSymbol"; -import { useCallback, useEffect, useMemo, useRef } from "react"; -import { View, type AccessibilityActionEvent } from "react-native"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; -import Animated, { - runOnJS, - useAnimatedStyle, - useSharedValue, - withTiming, -} from "react-native-reanimated"; -import type { ComponentProps } from "react"; - -import { AppText as Text } from "../../../../components/AppText"; - -type SymbolName = ComponentProps["name"]; - -const THUMB_SIZE = 26; -const TRACK_HEIGHT = 4; -const SNAP_ANIMATION = { duration: 120 } as const; - -function clampFraction(value: number): number { - "worklet"; - return Math.min(1, Math.max(0, value)); -} - -export function FontSizeSliderRow(props: { - readonly disabled?: boolean; - readonly icon: SymbolName; - readonly label: string; - readonly valueLabel: string; - readonly min: number; - readonly max: number; - readonly step: number; - readonly value: number; - readonly onChange: (value: number) => void; -}) { - const latest = useRef(props); - latest.current = props; - - const { min, max, step, value, disabled } = props; - const fraction = (value - min) / (max - min); - - const progress = useSharedValue(clampFraction(fraction)); - const trackWidth = useSharedValue(0); - const dragging = useSharedValue(false); - - useEffect(() => { - if (!dragging.value) { - progress.value = withTiming(clampFraction(fraction), SNAP_ANIMATION); - } - }, [dragging, fraction, progress]); - - const commit = useCallback((next: number) => { - const current = latest.current; - if (next === current.value) { - return; - } - Haptics.selectionAsync().catch(() => undefined); - current.onChange(next); - }, []); - - const gesture = useMemo(() => { - const snapValue = (raw: number): number => { - "worklet"; - const stepped = Math.round((raw - min) / step) * step + min; - return Math.min(max, Math.max(min, stepped)); - }; - const fractionAt = (x: number): number => { - "worklet"; - const usable = trackWidth.value - THUMB_SIZE; - if (usable <= 0) { - return 0; - } - return clampFraction((x - THUMB_SIZE / 2) / usable); - }; - const valueAtFraction = (f: number): number => { - "worklet"; - return snapValue(min + f * (max - min)); - }; - const fractionOfValue = (v: number): number => { - "worklet"; - return clampFraction((v - min) / (max - min)); - }; - - const pan = Gesture.Pan() - .enabled(!disabled) - .activeOffsetX([-8, 8]) - .failOffsetY([-12, 12]) - .onUpdate((event) => { - dragging.value = true; - const f = fractionAt(event.x); - progress.value = f; - }) - .onFinalize((_event, success) => { - if (!dragging.value) { - return; - } - dragging.value = false; - if (!success) { - progress.value = withTiming(fractionOfValue(value), SNAP_ANIMATION); - return; - } - const next = valueAtFraction(progress.value); - progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION); - runOnJS(commit)(next); - }); - - const tap = Gesture.Tap() - .enabled(!disabled) - .onEnd((event) => { - const next = valueAtFraction(fractionAt(event.x)); - progress.value = withTiming(fractionOfValue(next), SNAP_ANIMATION); - runOnJS(commit)(next); - }); - - return Gesture.Race(pan, tap); - }, [commit, disabled, dragging, max, min, progress, step, trackWidth, value]); - - const fillStyle = useAnimatedStyle(() => ({ - width: THUMB_SIZE / 2 + progress.value * Math.max(0, trackWidth.value - THUMB_SIZE), - })); - const thumbStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: progress.value * Math.max(0, trackWidth.value - THUMB_SIZE) }], - })); - - const handleAccessibilityAction = (event: AccessibilityActionEvent) => { - if (event.nativeEvent.actionName === "increment") { - commit(Math.min(max, value + step)); - } else if (event.nativeEvent.actionName === "decrement") { - commit(Math.max(min, value - step)); - } - }; - - return ( - - - - {props.label} - {props.valueLabel} - - - - - { - trackWidth.value = event.nativeEvent.layout.width; - }} - > - - - - - - - - - - ); -} +export { FontSizeSliderRow } from "./FontSizeSliderRow.shared"; diff --git a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx index a677cc5707d2..788d04b047ba 100644 --- a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx @@ -1,5 +1,5 @@ import { memo, useId } from "react"; -import { Platform, Pressable, View } from "react-native"; +import { Pressable, View } from "react-native"; import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; import { ScopedTheme, ScopedVariables } from "uniwind"; @@ -19,9 +19,6 @@ import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime"; import { cn } from "../../../../lib/cn"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; -import { SettingsSection } from "../../components/SettingsSection"; -import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; - const APPEARANCE_MODES: ReadonlyArray<{ readonly id: MobileThemeMode; readonly label: string; @@ -293,25 +290,11 @@ export function ThemeAppearanceSection() { setThemeMode, themeIds, themeMode, - materialYouStyleLayoutEnabled, - setMaterialYouStyleLayoutEnabled, systemColorsAvailable, } = useAppearancePreferences(); return ( - {Platform.OS === "android" ? ( - - - - ) : null} Color scheme diff --git a/apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx b/apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx new file mode 100644 index 000000000000..bc9ccafecf7e --- /dev/null +++ b/apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx @@ -0,0 +1,50 @@ +import { + MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, +} from "@t3tools/contracts"; +import { View } from "react-native"; + +import { AppText } from "../../../components/AppText"; +import { MaterialIconButton } from "../../../components/MaterialIconButton"; +import type { AutoSettleDaysFieldProps } from "./AutoSettleDaysField"; + +export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { + const adjust = (amount: number) => { + const next = Math.max( + MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + Math.min(MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, props.value + amount), + ); + if (next !== props.value) props.onValueChange(next); + }; + + return ( + + {/* Match the 32dp switch track while retaining 48dp button touch targets. */} + + adjust(-1)} + /> + + {props.value} + + = MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS} + onPress={() => adjust(1)} + /> + + ); +} diff --git a/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx b/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx new file mode 100644 index 000000000000..19a8998084c6 --- /dev/null +++ b/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx @@ -0,0 +1,94 @@ +import { Button, Host, HStack, Picker, Popover, Text, VStack } from "@expo/ui/swift-ui"; +import { + accessibilityLabel, + buttonStyle, + font, + foregroundStyle, + frame, + padding, + pickerStyle, + presentationBackground, + tag, +} from "@expo/ui/swift-ui/modifiers"; +import { + MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, +} from "@t3tools/contracts"; +import { useState } from "react"; + +import { useAppearancePreferences } from "../appearance/AppearancePreferencesProvider"; +import type { AutoSettleDaysFieldProps } from "./AutoSettleDaysField"; + +const days = Array.from( + { length: MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS - MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS + 1 }, + (_, index) => MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS + index, +); + +export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { + const { themeAppearance, themeVariables: colors, appearance } = useAppearancePreferences(); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(props.value); + return ( + + + + + + + + + {days.map((value) => ( + + {`${value} ${value === 1 ? "day" : "days"}`} + + ))} + + + + + + + + + + + ); +} + +function SnoozeDateTimePicker(props: { + readonly date: Date; + readonly picker: "date" | "time"; + readonly is24Hour: boolean; + readonly colors: React.ComponentProps["elementColors"]; + readonly onChange: (date: Date) => void; +}) { + // Changing initialDate resets Compose's picker state, including its active clock dial. + const [initialDate] = useState(() => + props.picker === "date" ? snoozeDateToPickerDate(props.date) : props.date.toISOString(), + ); + return ( + + ); +} diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx index 7f5b69ed224a..20176064be52 100644 --- a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx @@ -3,7 +3,6 @@ import { DatePicker, Host, HStack, - Menu, Picker, Popover, Spacer, @@ -11,16 +10,20 @@ import { VStack, } from "@expo/ui/swift-ui"; import { - background, + accessibilityAddTraits, + accessibilityHidden, + buttonBorderShape, buttonStyle, - datePickerStyle, + clipped, + controlSize, font, + datePickerStyle, foregroundStyle, frame, + labelsHidden, + labelStyle, padding, pickerStyle, - presentationBackground, - shapes, tag, } from "@expo/ui/swift-ui/modifiers"; import { @@ -30,9 +33,8 @@ import { type CustomSnoozeInput, } from "@t3tools/client-runtime/state/thread-settled"; import { useState } from "react"; -import { Modal, Pressable, ScrollView, View } from "react-native"; -import { AppText } from "../../components/AppText"; -import { SegmentedControl } from "../../components/SegmentedControl"; +import { useWindowDimensions } from "react-native"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1); @@ -50,13 +52,13 @@ export function CustomSnoozeSheet(props: { readonly onClose: () => void; readonly onSnooze: (snoozedUntil: string) => void; }) { + const { width } = useWindowDimensions(); const [mode, setMode] = useState("date"); const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); const [amount, setAmount] = useState(2); - const [amountOpen, setAmountOpen] = useState(false); const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); const [error, setError] = useState(null); - const { themeVariables: colors, themeAppearance, appearance } = useAppearancePreferences(); + const { themeVariables: colors, themeAppearance } = useAppearancePreferences(); const updateDate = (value: Date) => { setDate(value); setError(null); @@ -79,190 +81,150 @@ export function CustomSnoozeSheet(props: { }; return ( - - - - - - Cancel - - - Custom snooze - - - Snooze - - - { - setMode(value); - setError(null); - }} - role="tab" + + { + if (!presented) props.onClose(); + }} + > + + - + + - - {mode === "date" ? "Until" : "Snooze for"} - - {mode === "date" ? ( - <> - - - - ) : ( - <> - - - - - - - { - setAmount(value); - setError(null); - }} - modifiers={[pickerStyle("wheel"), frame({ height: 160 })]} - > - {durationAmounts.map((value) => ( - - {String(value)} - - ))} - - - - - - - ", - " styles:", - " color: red;", - "", - ].join("\n"), - ); - const event = new TestClipboardEvent(copied.text, { - "web application/x-t3-context-fragment+json": JSON.stringify({ - version: 1, - source: { environmentId: "env-1" }, - records: copied.records, - }), - }); - const annotations: ReturnType[] = []; - const text = importPastedComposerText(event.clipboardData, (fragment) => { - const record = fragment.records[0]!; - if (record.kind !== "element" || "payload" in record) throw new Error("Expected element"); - const annotation = previewAnnotationContextRecord( - elementContextToPreviewAnnotation(record, "imported", "2026-01-01T00:00:00Z"), - ); - annotations.push(annotation); - return new Map([[record.contextId, annotation.contextId]]); - }); - expect(text).toContain("t3-context://v1/preview-annotation/preview-annotation_imported"); - expect(annotations[0]?.elements?.[0]).toMatchObject({ - selector: "#save", - htmlPreview: "", - styles: "color: red;", - }); - }); - - it("imports an annotation's dependent screenshot when only its chip is pasted", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createEditor({ nodes: [ComposerCitationNode] }); - editor.update( - () => { - const paragraph = $createParagraphNode(); - $getRoot().append(paragraph); - paragraph.selectEnd(); - }, - { discrete: true }, - ); - const imported: string[] = []; - registerComposerInlineTokenPaste(editor, { - createMentionNode: (path) => $createTextNode(``), - createCitationNode: $createComposerCitationNode, - createContextReferenceNode: (reference) => - $createTextNode(``), - getExpandedAbsoluteOffsetForPoint: () => 0, - importContextFragment: (fragment) => { - imported.push(...fragment.records.map((record) => record.contextId)); - return new Map(); - }, - }); - const annotationId = "preview-annotation_ann-1"; - const screenshotId = "image_ann-1"; - const event = new TestClipboardEvent( - `[Fix button](t3-context://v1/preview-annotation/${annotationId})`, - { - "web application/x-t3-context-fragment+json": JSON.stringify({ - version: 1, - source: { environmentId: "env-1" }, - records: [ - { - version: 1, - contextId: annotationId, - kind: "preview-annotation", - label: "Fix button", - annotationId: "ann-1", - pageUrl: "https://example.com", - pageTitle: "Example", - comment: "Fix button", - targetSummary: "1 selected element", - styleChanges: [], - screenshotContextId: screenshotId, - }, - { - version: 1, - contextId: screenshotId, - kind: "image", - label: "annotation.png", - attachmentId: "attachment-1", - name: "annotation.png", - mimeType: "image/png", - sizeBytes: 10, - }, - { - version: 1, - contextId: "image_unrelated", - kind: "image", - label: "unrelated.png", - attachmentId: "attachment-2", - name: "unrelated.png", - mimeType: "image/png", - sizeBytes: 10, - }, - ], - }), - }, - ); - editor.update( - () => { - editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); - }, - { discrete: true }, - ); - - expect(imported).toEqual([annotationId, screenshotId]); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( - ``, - ); - }); - - it("turns pasted context links into reference nodes", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createCitationEditor("see "); - pasteText(editor, "[Terminal 1 line 4](t3-context://v1/terminal/ctx-1) now"); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( - "see now", - ); - }); -}); - -describe("citation comment opening", () => { - const sourceAnchor: AssistantCitationSourceAnchor = { - source: { nodeType: 1 } as HTMLElement, - range: { collapsed: false } as Range, - viewport: { nodeType: 1 } as HTMLElement, - }; - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("opens only the newly inserted occurrence after committing its exact prompt", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createCitationEditor(); - const previousValue = `前 ${citationSource} middle\nbefore after ${citationSource} 後`; - pasteText(editor, previousValue); - const citationStart = `前 ${citationSource} middle\nbefore `.length; - const value = - previousValue.slice(0, citationStart) + - `${citationSource} ` + - previousValue.slice(citationStart); - const request: ComposerCitationCommentRequest = { - previousValue, - value, - citationStart, - sourceAnchor, - }; - const requestRef = { current: request as ComposerCitationCommentRequest | null }; - - editor.getEditorState().read(() => { - expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull(); - expect(requestRef.current).toBe(request); - }); - editor.update( - () => { - const text = $getRoot() - .getAllTextNodes() - .find((node) => node.getTextContent() === "before after "); - if (!text) throw new Error("Expected insertion text"); - text.select("before ".length, "before ".length); - }, - { discrete: true }, - ); - pasteText(editor, `${citationSource} `); - - editor.getEditorState().read(() => { - const selection = $getSelection()?.clone(); - expect($getRoot().getTextContent()).toBe(value); - expect($citationNodes()).toHaveLength(3); - const target = $consumeComposerCitationCommentRequest(requestRef); - expect(target?.nodeKey).toBe($citationNodes()[1]!.getKey()); - expect(target?.sourceAnchor).toBe(sourceAnchor); - expect($getSelection()?.is(selection!)).toBe(true); - expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull(); - }); - }); - - it("does not reopen comments when an inserted citation is restored or pasted", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createCitationEditor("before "); - const previousState = editor.getEditorState(); - const value = `before ${citationSource} `; - const requestRef: { current: ComposerCitationCommentRequest | null } = { - current: { previousValue: "before ", value, citationStart: "before ".length, sourceAnchor }, - }; - pasteText(editor, `${citationSource} `); - const insertedState = editor.getEditorState(); - insertedState.read(() => { - expect($consumeComposerCitationCommentRequest(requestRef)?.nodeKey).toBe( - $citationNodes()[0]!.getKey(), - ); - }); - editor.update(() => $citationNodes()[0]!.setComment("Keep this attached."), { - discrete: true, - }); - const commentedState = editor.getEditorState(); - - for (const restoredState of [previousState, insertedState, commentedState]) { - editor.setEditorState(restoredState); - editor.getEditorState().read(() => { - expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull(); - }); - } - - const reloaded = createCitationEditor(); - reloaded.setEditorState(reloaded.parseEditorState(commentedState.toJSON())); - reloaded.getEditorState().read(() => { - expect($consumeComposerCitationCommentRequest({ current: null })).toBeNull(); - expect($citationNodes()[0]!.exportJSON().citation.comment).toBe("Keep this attached."); - }); - const pasted = createCitationEditor(); - pasteText( - pasted, - commentedState.read(() => $getRoot().getTextContent()), - ); - pasted.getEditorState().read(() => { - expect($consumeComposerCitationCommentRequest({ current: null })).toBeNull(); - expect($citationNodes()[0]!.exportJSON().citation.comment).toBe("Keep this attached."); - }); - }); - - it("discards an opening request if another prompt replaces the insertion", () => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createCitationEditor(); - const requestRef: { current: ComposerCitationCommentRequest | null } = { - current: { previousValue: "", value: citationSource, citationStart: 0, sourceAnchor }, - }; - editor.update( - () => { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) throw new Error("Expected an insertion point"); - selection.insertText("A different draft"); - }, - { discrete: true }, - ); - editor.getEditorState().read(() => { - expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull(); - }); - editor.update( - () => { - const paragraph = $getRoot().getFirstChildOrThrow(); - if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph"); - paragraph.clear().selectStart(); - }, - { discrete: true }, - ); - pasteText(editor, citationSource); - editor.getEditorState().read(() => { - expect($getRoot().getTextContent()).toBe(citationSource); - expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull(); - }); - }); -}); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 3ba086e0e981..7e699b6e0304 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1,2137 +1,17 @@ -import { ContextChipPopover } from "./contextChipParts"; -import { Button } from "./ui/button"; -import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer"; -import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import { ContentEditable } from "@lexical/react/LexicalContentEditable"; -import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; -import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; -import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; -import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; -import { - type ComposerContextClipboardFragment, - type ServerProviderSkill, -} from "@t3tools/contracts"; -import { - COMPOSER_CONTEXT_CLIPBOARD_MIME, - encodeComposerContextClipboardHtml, -} from "@t3tools/shared/composerContextClipboard"; -import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; -import { - $applyNodeReplacement, - $createRangeSelectionFromDom, - $createRangeSelection, - $getSelection, - $setSelection, - $isElementNode, - $isLineBreakNode, - $isRangeSelection, - $isTextNode, - $createLineBreakNode, - $createParagraphNode, - $createTextNode, - KEY_ARROW_DOWN_COMMAND, - KEY_ARROW_LEFT_COMMAND, - KEY_ARROW_RIGHT_COMMAND, - KEY_ARROW_UP_COMMAND, - KEY_DOWN_COMMAND, - KEY_ENTER_COMMAND, - KEY_TAB_COMMAND, - COMMAND_PRIORITY_HIGH, - COPY_COMMAND, - CUT_COMMAND, - COMMAND_PRIORITY_LOW, - KEY_BACKSPACE_COMMAND, - BLUR_COMMAND, - FOCUS_COMMAND, - $getRoot, - $getNodeByKey, - HISTORY_MERGE_TAG, - HISTORY_PUSH_TAG, - SKIP_DOM_SELECTION_TAG, - DecoratorNode, - type ElementNode, - type LexicalNode, - type SerializedLexicalNode, - type EditorState, - type NodeKey, - type Spread, -} from "lexical"; -import { - createContext, - use, - useCallback, - useEffect, - useEffectEvent, - useImperativeHandle, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { ComposerPromptEditorTiptap } from "./ComposerPromptEditorTiptap"; +import type { ComposerPromptEditorProps } from "./ComposerPromptEditorTiptap"; -import { - clampCollapsedComposerCursor, - collapseExpandedComposerCursor, - expandCollapsedComposerCursor, - isCollapsedCursorAdjacentToInlineToken, -} from "~/composer-logic"; -import { - selectionTouchesMentionBoundary, - splitPromptIntoComposerSegments, -} from "~/composer-editor-mentions"; -import { collectInlineContextIds } from "~/lib/composerContextReferences"; -import { cn, isMacPlatform } from "~/lib/utils"; -import { basenameOfPath } from "~/pierre-icons"; -import { - COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME, - COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, - COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, - COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, - SKILL_CHIP_ICON_SVG, -} from "./composerInlineChip"; -import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; -import { getTimelinePageScrollKey } from "./chat/pageScrollController"; -import { - $createComposerContextReferenceNode, - ComposerContextReferenceNode, -} from "./ComposerContextReferenceNode"; -import { - ComposerContextActionsContext, - ComposerContextRecordsContext, - type ComposerDraftContextRecords, -} from "./composerContextPresentation"; -import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; -import { didComposerSelectionChangeVisibly } from "./composerSelection"; -import { - $consumeComposerCitationCommentRequest, - $createComposerCitationNode, - ComposerCitationCommentContext, - ComposerCitationNode, - type ComposerCitationCommentRequest, - type ComposerCitationCommentTarget, -} from "./ComposerCitationNode"; - -const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; -const SURROUND_SYMBOLS: [string, string][] = [ - ["(", ")"], - ["[", "]"], - ["{", "}"], - ["'", "'"], - ['"', '"'], - ["“", "”"], - ["`", "`"], - ["<", ">"], - ["«", "»"], - ["*", "*"], - ["_", "_"], -]; -const SURROUND_SYMBOLS_MAP = new Map(SURROUND_SYMBOLS); -const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null; - -type SerializedComposerMentionNode = Spread< - { - path: string; - source?: string; - type: "composer-mention"; - version: 1; - }, - SerializedLexicalNode ->; - -type SerializedComposerSkillNode = Spread< - { - skillName: string; - skillLabel?: string; - skillDescription?: string; - type: "composer-skill"; - version: 1; - }, - SerializedLexicalNode ->; - -function ComposerMentionDecorator(props: { path: string }) { - const actions = use(ComposerContextActionsContext); - const theme = resolvedThemeFromDocument(); - const chip = ( - - ); - - return ( - - - - {props.path} - - - ); -} - -class ComposerMentionNode extends DecoratorNode { - __path: string; - __source: string; - - static override getType(): string { - return "composer-mention"; - } - - static override clone(node: ComposerMentionNode): ComposerMentionNode { - return new ComposerMentionNode(node.__path, node.__source, node.__key); - } - - static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode { - return $createComposerMentionNode(serializedNode.path, serializedNode.source).updateFromJSON( - serializedNode, - ); - } - - constructor(path: string, source = serializeComposerFileLink(path), key?: NodeKey) { - super(key); - this.__path = path; - this.__source = source; - } - - override exportJSON(): SerializedComposerMentionNode { - return { - ...super.exportJSON(), - path: this.__path, - source: this.__source, - type: "composer-mention", - version: 1, - }; - } - - override createDOM(): HTMLElement { - const dom = document.createElement("span"); - dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; - return dom; - } - - override updateDOM(): false { - return false; - } - - override getTextContent(): string { - return this.__source; - } - - override isInline(): true { - return true; - } - - override decorate(): React.ReactElement { - return ; - } -} - -function $createComposerMentionNode(path: string, source?: string): ComposerMentionNode { - return $applyNodeReplacement(new ComposerMentionNode(path, source)); -} - -function resolveSkillDescription( - skill: Pick, -): string | null { - const shortDescription = skill.shortDescription?.trim(); - if (shortDescription) { - return shortDescription; - } - const description = skill.description?.trim(); - return description || null; -} - -type ComposerSkillMetadata = { - label: string; - description: string | null; -}; - -function skillMetadataByName( - skills: ReadonlyArray, -): ReadonlyMap { - return new Map( - skills.map((skill) => [ - skill.name, - { - label: formatProviderSkillDisplayName(skill), - description: resolveSkillDescription(skill), - }, - ]), - ); -} - -const ComposerSkillsContext = createContext>([]); - -function ComposerSkillDecorator(props: { - skillName: string; - skillLabel: string; - skillDescription: string | null; -}) { - const actions = use(ComposerContextActionsContext); - const skill = use(ComposerSkillsContext).find((candidate) => candidate.name === props.skillName); - return ( - - - ); -} - -class ComposerSkillNode extends DecoratorNode { - __skillName: string; - __skillLabel: string; - __skillDescription: string | null; - - static override getType(): string { - return "composer-skill"; - } - - static override clone(node: ComposerSkillNode): ComposerSkillNode { - return new ComposerSkillNode( - node.__skillName, - node.__skillLabel, - node.__skillDescription, - node.__key, - ); - } - - static override importJSON(serializedNode: SerializedComposerSkillNode): ComposerSkillNode { - return $createComposerSkillNode( - serializedNode.skillName, - serializedNode.skillLabel ?? serializedNode.skillName, - serializedNode.skillDescription ?? null, - ).updateFromJSON(serializedNode); - } - - constructor( - skillName: string, - skillLabel: string, - skillDescription: string | null, - key?: NodeKey, - ) { - super(key); - const normalizedSkillName = skillName.startsWith("$") ? skillName.slice(1) : skillName; - this.__skillName = normalizedSkillName; - this.__skillLabel = skillLabel; - this.__skillDescription = skillDescription; - } - - override exportJSON(): SerializedComposerSkillNode { - return { - ...super.exportJSON(), - skillName: this.__skillName, - skillLabel: this.__skillLabel, - ...(this.__skillDescription ? { skillDescription: this.__skillDescription } : {}), - type: "composer-skill", - version: 1, - }; - } - - override createDOM(): HTMLElement { - const dom = document.createElement("span"); - dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; - return dom; - } - - override updateDOM(): false { - return false; - } - - override getTextContent(): string { - return `$${this.__skillName}`; - } - - override isInline(): true { - return true; - } - - override decorate(): React.ReactElement { - return ( - - ); - } -} - -function $createComposerSkillNode( - skillName: string, - skillLabel: string, - skillDescription: string | null, -): ComposerSkillNode { - return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel, skillDescription)); -} - -type ComposerInlineTokenNode = - | ComposerMentionNode - | ComposerSkillNode - | ComposerCitationNode - | ComposerContextReferenceNode; - -function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { - return ( - candidate instanceof ComposerMentionNode || - candidate instanceof ComposerSkillNode || - candidate instanceof ComposerCitationNode || - candidate instanceof ComposerContextReferenceNode - ); -} - -function resolvedThemeFromDocument(): "light" | "dark" { - return document.documentElement.classList.contains("dark") ? "dark" : "light"; -} - -function skillSignature(skills: ReadonlyArray): string { - return skills - .map((skill) => - [ - skill.name, - skill.displayName ?? "", - skill.shortDescription ?? "", - skill.description ?? "", - skill.path, - skill.scope ?? "", - skill.enabled ? "1" : "0", - ].join("\u001f"), - ) - .join("\u001e"); -} - -function clampExpandedCursor(value: string, cursor: number): number { - if (!Number.isFinite(cursor)) return value.length; - return Math.max(0, Math.min(value.length, Math.floor(cursor))); -} - -function getComposerInlineTokenTextLength(_node: ComposerInlineTokenNode): 1 { - return 1; -} - -function getComposerInlineTokenExpandedTextLength(node: ComposerInlineTokenNode): number { - return node.getTextContentSize(); -} - -function getAbsoluteOffsetForInlineTokenPoint( - node: ComposerInlineTokenNode, - absoluteOffset: number, - pointOffset: number, -): number { - return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenTextLength(node) : 0); -} - -function getExpandedAbsoluteOffsetForInlineTokenPoint( - node: ComposerInlineTokenNode, - absoluteOffset: number, - pointOffset: number, -): number { - return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenExpandedTextLength(node) : 0); -} - -function findSelectionPointForInlineToken( - node: ComposerInlineTokenNode, - remainingRef: { value: number }, -): { key: string; offset: number; type: "element" } | null { - const parent = node.getParent(); - if (!parent || !$isElementNode(parent)) return null; - const index = node.getIndexWithinParent(); - if (remainingRef.value === 0) { - return { - key: parent.getKey(), - offset: index, - type: "element", - }; - } - if (remainingRef.value === getComposerInlineTokenTextLength(node)) { - return { - key: parent.getKey(), - offset: index + 1, - type: "element", - }; - } - remainingRef.value -= getComposerInlineTokenTextLength(node); - return null; -} - -function getComposerNodeTextLength(node: LexicalNode): number { - if (isComposerInlineTokenNode(node)) { - return getComposerInlineTokenTextLength(node); - } - if ($isTextNode(node)) { - return node.getTextContentSize(); - } - if ($isLineBreakNode(node)) { - return 1; - } - if ($isElementNode(node)) { - return node.getChildren().reduce((total, child) => total + getComposerNodeTextLength(child), 0); - } - return 0; -} - -function getComposerNodeExpandedTextLength(node: LexicalNode): number { - if (isComposerInlineTokenNode(node)) { - return getComposerInlineTokenExpandedTextLength(node); - } - if ($isTextNode(node)) { - return node.getTextContentSize(); - } - if ($isLineBreakNode(node)) { - return 1; - } - if ($isElementNode(node)) { - return node - .getChildren() - .reduce((total, child) => total + getComposerNodeExpandedTextLength(child), 0); - } - return 0; -} - -function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { - let offset = 0; - let current: LexicalNode | null = node; - - while (current) { - const nextParent = current.getParent() as LexicalNode | null; - if (!nextParent || !$isElementNode(nextParent)) { - break; - } - const siblings = nextParent.getChildren(); - const index = current.getIndexWithinParent(); - for (let i = 0; i < index; i += 1) { - const sibling = siblings[i]; - if (!sibling) continue; - offset += getComposerNodeTextLength(sibling); - } - current = nextParent; - } - - if ($isTextNode(node)) { - return offset + Math.min(pointOffset, node.getTextContentSize()); - } - if (isComposerInlineTokenNode(node)) { - return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); - } - - if ($isLineBreakNode(node)) { - return offset + Math.min(pointOffset, 1); - } - - if ($isElementNode(node)) { - const children = node.getChildren(); - const clampedOffset = Math.max(0, Math.min(pointOffset, children.length)); - for (let i = 0; i < clampedOffset; i += 1) { - const child = children[i]; - if (!child) continue; - offset += getComposerNodeTextLength(child); - } - return offset; - } - - return offset; -} - -function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { - let offset = 0; - let current: LexicalNode | null = node; - - while (current) { - const nextParent = current.getParent() as LexicalNode | null; - if (!nextParent || !$isElementNode(nextParent)) { - break; - } - const siblings = nextParent.getChildren(); - const index = current.getIndexWithinParent(); - for (let i = 0; i < index; i += 1) { - const sibling = siblings[i]; - if (!sibling) continue; - offset += getComposerNodeExpandedTextLength(sibling); - } - current = nextParent; - } - - if ($isTextNode(node)) { - return offset + Math.min(pointOffset, node.getTextContentSize()); - } - if (isComposerInlineTokenNode(node)) { - return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); - } - - if ($isLineBreakNode(node)) { - return offset + Math.min(pointOffset, 1); - } - - if ($isElementNode(node)) { - const children = node.getChildren(); - const clampedOffset = Math.max(0, Math.min(pointOffset, children.length)); - for (let i = 0; i < clampedOffset; i += 1) { - const child = children[i]; - if (!child) continue; - offset += getComposerNodeExpandedTextLength(child); - } - return offset; - } - - return offset; -} - -function findSelectionPointAtOffset( - node: LexicalNode, - remainingRef: { value: number }, -): { key: string; offset: number; type: "text" | "element" } | null { - if (isComposerInlineTokenNode(node)) { - return findSelectionPointForInlineToken(node, remainingRef); - } - - if ($isTextNode(node)) { - const size = node.getTextContentSize(); - if (remainingRef.value <= size) { - return { - key: node.getKey(), - offset: remainingRef.value, - type: "text", - }; - } - remainingRef.value -= size; - return null; - } - - if ($isLineBreakNode(node)) { - const parent = node.getParent(); - if (!parent) return null; - const index = node.getIndexWithinParent(); - if (remainingRef.value === 0) { - return { - key: parent.getKey(), - offset: index, - type: "element", - }; - } - if (remainingRef.value === 1) { - return { - key: parent.getKey(), - offset: index + 1, - type: "element", - }; - } - remainingRef.value -= 1; - return null; - } - - if ($isElementNode(node)) { - const children = node.getChildren(); - for (const child of children) { - const point = findSelectionPointAtOffset(child, remainingRef); - if (point) { - return point; - } - } - if (remainingRef.value === 0) { - return { - key: node.getKey(), - offset: children.length, - type: "element", - }; - } - } - - return null; -} - -function $getComposerRootLength(): number { - const root = $getRoot(); - const children = root.getChildren(); - return children.reduce((sum, child) => sum + getComposerNodeTextLength(child), 0); -} - -function $setSelectionAtComposerOffset(nextOffset: number): void { - const root = $getRoot(); - const composerLength = $getComposerRootLength(); - const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength)); - const remainingRef = { value: boundedOffset }; - const point = findSelectionPointAtOffset(root, remainingRef) ?? { - key: root.getKey(), - offset: root.getChildren().length, - type: "element" as const, - }; - const selection = $createRangeSelection(); - selection.anchor.set(point.key, point.offset, point.type); - selection.focus.set(point.key, point.offset, point.type); - $setSelection(selection); -} - -function $setSelectionRangeAtComposerOffsets(startOffset: number, endOffset: number): void { - const root = $getRoot(); - const composerLength = $getComposerRootLength(); - const boundedStart = Math.max(0, Math.min(startOffset, composerLength)); - const boundedEnd = Math.max(0, Math.min(endOffset, composerLength)); - const anchorRemainingRef = { value: boundedStart }; - const focusRemainingRef = { value: boundedEnd }; - const anchorPoint = findSelectionPointAtOffset(root, anchorRemainingRef) ?? { - key: root.getKey(), - offset: root.getChildren().length, - type: "element" as const, - }; - const focusPoint = findSelectionPointAtOffset(root, focusRemainingRef) ?? { - key: root.getKey(), - offset: root.getChildren().length, - type: "element" as const, - }; - const selection = $createRangeSelection(); - selection.anchor.set(anchorPoint.key, anchorPoint.offset, anchorPoint.type); - selection.focus.set(focusPoint.key, focusPoint.offset, focusPoint.type); - $setSelection(selection); -} - -function getSelectionRangeForExpandedComposerOffsets(selection: ReturnType): { - start: number; - end: number; -} | null { - if (!$isRangeSelection(selection)) { - return null; - } - const anchorNode = selection.anchor.getNode(); - const focusNode = selection.focus.getNode(); - const anchorOffset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); - const focusOffset = getExpandedAbsoluteOffsetForPoint(focusNode, selection.focus.offset); - return { - start: Math.min(anchorOffset, focusOffset), - end: Math.max(anchorOffset, focusOffset), - }; -} - -function $selectionTouchesInlineToken(selection: ReturnType): boolean { - if (!$isRangeSelection(selection)) { - return false; - } - return selection.getNodes().some((node) => isComposerInlineTokenNode(node)); -} - -function $readSelectionOffsetFromEditorState(fallback: number): number { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) { - return fallback; - } - const anchorNode = selection.anchor.getNode(); - const offset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); - const composerLength = $getComposerRootLength(); - return Math.max(0, Math.min(offset, composerLength)); -} - -function $readExpandedSelectionOffsetFromEditorState(fallback: number): number { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) { - return fallback; - } - const anchorNode = selection.anchor.getNode(); - const offset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); - const expandedLength = $getRoot().getTextContent().length; - return Math.max(0, Math.min(offset, expandedLength)); -} - -function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { - const lines = text.split("\n"); - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index] ?? ""; - if (line.length > 0) { - parent.append($createTextNode(line)); - } - if (index < lines.length - 1) { - parent.append($createLineBreakNode()); - } - } -} - -function $setComposerEditorPrompt( - prompt: string, - skillMetadata: ReadonlyMap, -): void { - const root = $getRoot(); - root.clear(); - const paragraph = $createParagraphNode(); - root.append(paragraph); - - const segments = splitPromptIntoComposerSegments(prompt); - for (const segment of segments) { - if (segment.type === "citation") { - paragraph.append($createComposerCitationNode(segment.citation, segment.source)); - continue; - } - if (segment.type === "mention") { - paragraph.append($createComposerMentionNode(segment.path, segment.source)); - continue; - } - if (segment.type === "skill") { - const metadata = skillMetadata.get(segment.name); - paragraph.append( - $createComposerSkillNode( - segment.name, - metadata?.label ?? formatProviderSkillDisplayName({ name: segment.name }), - metadata?.description ?? null, - ), - ); - continue; - } - if (segment.type === "context-reference") { - paragraph.append( - $createComposerContextReferenceNode({ - kind: segment.kind, - contextId: segment.contextId, - label: segment.label, - }), - ); - continue; - } - $appendTextWithLineBreaks(paragraph, segment.text); - } -} - -function collectContextIdOccurrences(node: LexicalNode): string[] { - if (node instanceof ComposerContextReferenceNode) { - return [node.__contextId]; - } - if ($isElementNode(node)) { - return node.getChildren().flatMap((child) => collectContextIdOccurrences(child)); - } - return []; -} - -/** Payload ids referenced by the document, once each in first-occurrence order. */ -function collectContextIds(node: LexicalNode): string[] { - return Array.from(new Set(collectContextIdOccurrences(node))); -} - -export interface ComposerPromptEditorHandle { - focus: () => void; - focusAt: (cursor: number) => void; - focusAtEnd: () => void; - readSelectionRange: () => { start: number; end: number }; - requestCitationComment: (request: ComposerCitationCommentRequest) => void; - readSnapshot: () => { - value: string; - cursor: number; - expandedCursor: number; - contextIds: string[]; - }; - /** - * True when a collapsed caret sits on the first ("start") or last ("end") - * visual line, counting soft wraps. Prompt history only claims ArrowUp and - * ArrowDown at these edges so arrows still move the caret inside multiline - * text. - */ - isCaretOnVisualEdge: (edge: "start" | "end") => boolean; -} - -interface ComposerPromptEditorProps { - value: string; - cursor: number; - /** Draft records behind the prompt's context references, keyed by context id. */ - contextRecords: ComposerDraftContextRecords; - /** Structured clipboard payload for the given referenced ids, or null to skip. */ - buildContextClipboardFragment?: - | ((contextIds: ReadonlyArray) => string | null) - | undefined; - /** Imports a structured paste's records; returns ids that changed. */ - importContextFragment?: - | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap) - | undefined; - skills: ReadonlyArray; - disabled: boolean; - placeholder: string; - containerClassName?: string; - className?: string; - placeholderClassName?: string; - onChange: ( - nextValue: string, - nextCursor: number, - expandedCursor: number, - cursorAdjacentToMention: boolean, - contextIds: string[], - ) => void; - onVisibleSelectionChange?: () => void; - onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", - event: KeyboardEvent, - ) => boolean; - onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; - onPageScrollKeyUp?: (key: string) => void; - onPageScrollRelease?: () => void; - onCitationSubmitAndSend?: () => void; - onPaste: React.ClipboardEventHandler; - editorRef: React.RefObject; -} +export type { + ComposerCitationCommentRequest, + ComposerPromptEditorHandle, + ComposerPromptEditorProps, +} from "./ComposerPromptEditorTiptap"; /** - * Client rect of the line the collapsed caret is on, as seen from `edge`. - * A caret at a soft-wrap boundary belongs to two visual lines and the - * range reports a rect for each, so take the one farthest from the edge - * under test: an ambiguous caret then never claims the key and the arrow - * moves the caret as usual. A collapsed range reports zero-height rects at - * some positions, so probe the adjacent character on the same side. When - * the range container is the paragraph itself (an empty line, or a caret - * beside an inline chip) measure the child next to the caret before - * falling back to the paragraph. + * The composer editor. Tiptap in both modes: the `richTextEnabled` setting + * toggles Markdown styling, never the engine. Plain mode renders every + * marker as a literal character and serializes byte-identically. */ -function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null { - const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0); - const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0]; - if (collapsedRect) return collapsedRect; - - const container = range.startContainer; - if (container.nodeType === Node.TEXT_NODE) { - const textNode = container as Text; - if (textNode.data.length === 0) return null; - const probeStart = Math.max( - 0, - Math.min( - edge === "start" ? range.startOffset : range.startOffset - 1, - textNode.data.length - 1, - ), - ); - const probeRange = document.createRange(); - probeRange.setStart(textNode, probeStart); - probeRange.setEnd(textNode, probeStart + 1); - const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); - if (probeRect) return probeRect; - const boundingRect = probeRange.getBoundingClientRect(); - return boundingRect.height > 0 ? boundingRect : null; - } - - if (!(container instanceof HTMLElement)) return null; - // The caret sits between the paragraph's children, which is where Lexical - // puts it next to an inline chip. Measure the neighbouring child. - const neighbour = - container.childNodes[Math.max(0, range.startOffset - 1)] ?? - container.childNodes[range.startOffset]; - if (neighbour instanceof HTMLElement) { - const neighbourRect = neighbour.getBoundingClientRect(); - if (neighbourRect.height > 0) return neighbourRect; - } else if (neighbour instanceof Text && neighbour.data.length > 0) { - // Probe the character on the caret's side. A soft-wrapped text node's - // first rect is its first visual line, which may not be the caret's. - const isBeforeCaret = neighbour === container.childNodes[range.startOffset - 1]; - const probeStart = isBeforeCaret ? neighbour.data.length - 1 : 0; - const probeRange = document.createRange(); - probeRange.setStart(neighbour, probeStart); - probeRange.setEnd(neighbour, probeStart + 1); - const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); - if (probeRect) return probeRect; - } - const containerRect = container.getBoundingClientRect(); - return containerRect.height > 0 ? containerRect : null; -} - -function ComposerCommandKeyPlugin(props: { - onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", - event: KeyboardEvent, - ) => boolean; -}) { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - const handleCommand = ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", - event: KeyboardEvent | null, - ): boolean => { - if (!props.onCommandKeyDown || !event) { - return false; - } - - if (key === "Enter" && (event.isComposing || event.keyCode === 229)) { - event.stopPropagation(); - return true; - } - - const handled = props.onCommandKeyDown(key, event); - if (handled) { - event.preventDefault(); - event.stopPropagation(); - } - return handled; - }; - - const unregisterArrowDown = editor.registerCommand( - KEY_ARROW_DOWN_COMMAND, - (event) => handleCommand("ArrowDown", event), - COMMAND_PRIORITY_HIGH, - ); - const unregisterArrowUp = editor.registerCommand( - KEY_ARROW_UP_COMMAND, - (event) => handleCommand("ArrowUp", event), - COMMAND_PRIORITY_HIGH, - ); - const unregisterEnter = editor.registerCommand( - KEY_ENTER_COMMAND, - (event) => handleCommand("Enter", event), - COMMAND_PRIORITY_HIGH, - ); - const unregisterTab = editor.registerCommand( - KEY_TAB_COMMAND, - (event) => handleCommand("Tab", event), - COMMAND_PRIORITY_HIGH, - ); - - return () => { - unregisterArrowDown(); - unregisterArrowUp(); - unregisterEnter(); - unregisterTab(); - }; - }, [editor, props]); - - return null; -} - -function ComposerInlineTokenArrowPlugin() { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - const unregisterLeft = editor.registerCommand( - KEY_ARROW_LEFT_COMMAND, - (event) => { - let nextOffset: number | null = null; - editor.getEditorState().read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; - const currentOffset = $readSelectionOffsetFromEditorState(0); - if (currentOffset <= 0) return; - const promptValue = $getRoot().getTextContent(); - if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left")) { - return; - } - nextOffset = currentOffset - 1; - }); - if (nextOffset === null) return false; - const selectionOffset = nextOffset; - event?.preventDefault(); - event?.stopPropagation(); - editor.update(() => { - $setSelectionAtComposerOffset(selectionOffset); - }); - return true; - }, - COMMAND_PRIORITY_HIGH, - ); - const unregisterRight = editor.registerCommand( - KEY_ARROW_RIGHT_COMMAND, - (event) => { - let nextOffset: number | null = null; - editor.getEditorState().read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; - const currentOffset = $readSelectionOffsetFromEditorState(0); - const composerLength = $getComposerRootLength(); - if (currentOffset >= composerLength) return; - const promptValue = $getRoot().getTextContent(); - if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right")) { - return; - } - nextOffset = currentOffset + 1; - }); - if (nextOffset === null) return false; - const selectionOffset = nextOffset; - event?.preventDefault(); - event?.stopPropagation(); - editor.update(() => { - $setSelectionAtComposerOffset(selectionOffset); - }); - return true; - }, - COMMAND_PRIORITY_HIGH, - ); - return () => { - unregisterLeft(); - unregisterRight(); - }; - }, [editor]); - - return null; -} - -function ComposerHomeEndKeyPlugin() { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - return editor.registerCommand( - KEY_DOWN_COMMAND, - (event) => { - if (!isMacPlatform(navigator.platform)) { - return false; - } - if (event.key !== "Home" && event.key !== "End") { - return false; - } - if (event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { - return false; - } - - const rootElement = editor.getRootElement(); - const selection = window.getSelection(); - const anchorNode = selection?.anchorNode; - if (!rootElement || !selection || !anchorNode || !rootElement.contains(anchorNode)) { - return false; - } - if (selection.rangeCount === 0 || typeof selection.modify !== "function") { - return false; - } - - event.preventDefault(); - event.stopPropagation(); - - selection.modify( - event.shiftKey ? "extend" : "move", - event.key === "Home" ? "backward" : "forward", - "lineboundary", - ); - editor.update(() => { - $setSelection($createRangeSelectionFromDom(selection, editor)); - }); - return true; - }, - COMMAND_PRIORITY_HIGH, - ); - }, [editor]); - - return null; -} - -function ComposerInlineTokenSelectionNormalizePlugin() { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - return editor.registerUpdateListener(({ editorState }) => { - let afterOffset: number | null = null; - editorState.read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; - const anchorNode = selection.anchor.getNode(); - if (!isComposerInlineTokenNode(anchorNode)) return; - if (selection.anchor.offset === 0) return; - const beforeOffset = getAbsoluteOffsetForPoint(anchorNode, 0); - afterOffset = beforeOffset + 1; - }); - if (afterOffset !== null) { - queueMicrotask(() => { - editor.update(() => { - $setSelectionAtComposerOffset(afterOffset!); - }); - }); - } - }); - }, [editor]); - - return null; -} - -function ComposerInlineTokenBackspacePlugin() { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - return editor.registerCommand( - KEY_BACKSPACE_COMMAND, - (event) => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) { - return false; - } - - const anchorNode = selection.anchor.getNode(); - const removeInlineTokenNode = (candidate: unknown): boolean => { - if (!isComposerInlineTokenNode(candidate)) { - return false; - } - const tokenStart = getAbsoluteOffsetForPoint(candidate, 0); - candidate.remove(); - $setSelectionAtComposerOffset(tokenStart); - event?.preventDefault(); - return true; - }; - if (removeInlineTokenNode(anchorNode)) { - return true; - } - - if ($isTextNode(anchorNode)) { - if (selection.anchor.offset > 0) { - return false; - } - if (removeInlineTokenNode(anchorNode.getPreviousSibling())) { - return true; - } - const parent = anchorNode.getParent(); - if ($isElementNode(parent)) { - const index = anchorNode.getIndexWithinParent(); - if (index > 0 && removeInlineTokenNode(parent.getChildAtIndex(index - 1))) { - return true; - } - } - return false; - } - - if ($isElementNode(anchorNode)) { - const childIndex = selection.anchor.offset - 1; - if (childIndex >= 0 && removeInlineTokenNode(anchorNode.getChildAtIndex(childIndex))) { - return true; - } - } - - return false; - }, - COMMAND_PRIORITY_HIGH, - ); - }, [editor]); - - return null; -} - -/** - * Chips render as non-editable decorators, so the browser never paints the - * native text selection over them; without help, a selection spanning chips - * is only visible in the slivers between them. Mirror the selection onto the - * chips with a data attribute the stylesheet turns into a highlight overlay. - */ -function ComposerChipSelectionPlugin() { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - let selectedKeys = new Set(); - // Lexical keeps the range selection on blur without emitting an update, - // so focus is tracked separately; while blurred the native highlight is - // gone and the mirrored one has to go with it. - let hasFocus = editor.getRootElement() === document.activeElement; - - const applyKeys = (nextKeys: Set) => { - for (const key of selectedKeys) { - if (!nextKeys.has(key)) { - editor.getElementByKey(key)?.removeAttribute("data-composer-chip-selected"); - } - } - for (const key of nextKeys) { - editor.getElementByKey(key)?.setAttribute("data-composer-chip-selected", "true"); - } - selectedKeys = nextKeys; - }; - - const readSelectedKeys = () => { - const nextKeys = new Set(); - editor.getEditorState().read(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection) && !selection.isCollapsed()) { - for (const node of selection.getNodes()) { - if (node instanceof DecoratorNode) { - nextKeys.add(node.getKey()); - } - } - } - }); - return nextKeys; - }; - - const unregisterUpdate = editor.registerUpdateListener(() => { - applyKeys(hasFocus ? readSelectedKeys() : new Set()); - }); - const unregisterFocus = editor.registerCommand( - FOCUS_COMMAND, - () => { - hasFocus = true; - applyKeys(readSelectedKeys()); - return false; - }, - COMMAND_PRIORITY_LOW, - ); - const unregisterBlur = editor.registerCommand( - BLUR_COMMAND, - () => { - hasFocus = false; - applyKeys(new Set()); - return false; - }, - COMMAND_PRIORITY_LOW, - ); - return () => { - unregisterUpdate(); - unregisterFocus(); - unregisterBlur(); - }; - }, [editor]); - - return null; -} - -function ComposerInlineTokenPastePlugin(props: { - importContextFragment?: ComposerPromptEditorProps["importContextFragment"]; -}) { - const [editor] = useLexicalComposerContext(); - const importContextFragment = props.importContextFragment; - - useEffect( - () => - registerComposerInlineTokenPaste(editor, { - createMentionNode: $createComposerMentionNode, - createCitationNode: $createComposerCitationNode, - createContextReferenceNode: $createComposerContextReferenceNode, - getExpandedAbsoluteOffsetForPoint, - ...(importContextFragment ? { importContextFragment } : {}), - }), - [editor, importContextFragment], - ); - - return null; -} - -/** - * Copying chips must carry their payloads: the default copy writes the canonical links as - * text, and this adds the structured fragment for the referenced records beside it. - */ -function ComposerContextClipboardPlugin(props: { - buildContextClipboardFragment?: ComposerPromptEditorProps["buildContextClipboardFragment"]; -}) { - const [editor] = useLexicalComposerContext(); - const build = props.buildContextClipboardFragment; - - useEffect(() => { - if (!build) return; - const listener = (event: ClipboardEvent | KeyboardEvent | null, cut: boolean) => { - if (!event || !("clipboardData" in event) || !event.clipboardData) return false; - const selection = $getSelection(); - if (!$isRangeSelection(selection) || selection.isCollapsed()) return false; - const text = selection.getTextContent(); - const contextIds = collectInlineContextIds(text); - if (contextIds.length === 0) return false; - const fragment = build(contextIds); - if (!fragment) return false; - event.preventDefault(); - event.clipboardData.setData("text/plain", text); - event.clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment); - event.clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment)); - if (cut) selection.removeText(); - return true; - }; - const unregisterCopy = editor.registerCommand( - COPY_COMMAND, - (event) => listener(event, false), - COMMAND_PRIORITY_HIGH, - ); - const unregisterCut = editor.registerCommand( - CUT_COMMAND, - (event) => listener(event, true), - COMMAND_PRIORITY_HIGH, - ); - return () => { - unregisterCopy(); - unregisterCut(); - }; - }, [build, editor]); - - return null; -} - -function ComposerSurroundSelectionPlugin(props: { skills: ReadonlyArray }) { - const [editor] = useLexicalComposerContext(); - const skillMetadataRef = useRef(skillMetadataByName(props.skills)); - const pendingSurroundSelectionRef = useRef<{ - value: string; - expandedStart: number; - expandedEnd: number; - } | null>(null); - const pendingDeadKeySelectionRef = useRef<{ - value: string; - expandedStart: number; - expandedEnd: number; - } | null>(null); - - useEffect(() => { - skillMetadataRef.current = skillMetadataByName(props.skills); - }, [props.skills]); - - const applySurroundInsertion = useEffectEvent((inputData: string): boolean => { - const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData); - const pendingSurroundSelection = pendingSurroundSelectionRef.current; - if (!surroundCloseSymbol) { - pendingSurroundSelectionRef.current = null; - return false; - } - - let handled = false; - editor.update(() => { - const selectionSnapshot = - pendingSurroundSelection ?? - (() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || selection.isCollapsed()) { - return null; - } - if ($selectionTouchesInlineToken(selection)) { - return null; - } - const range = getSelectionRangeForExpandedComposerOffsets(selection); - if (!range || range.start === range.end) { - return null; - } - const value = $getRoot().getTextContent(); - if (selectionTouchesMentionBoundary(value, range.start, range.end)) { - return null; - } - return { - value, - expandedStart: range.start, - expandedEnd: range.end, - }; - })(); - - if (!selectionSnapshot || !surroundCloseSymbol) { - return; - } - - const selectedText = selectionSnapshot.value.slice( - selectionSnapshot.expandedStart, - selectionSnapshot.expandedEnd, - ); - const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; - $setComposerEditorPrompt(nextValue, skillMetadataRef.current); - const selectionStart = collapseExpandedComposerCursor( - nextValue, - selectionSnapshot.expandedStart, - ); - $setSelectionRangeAtComposerOffsets( - selectionStart + inputData.length, - selectionStart + inputData.length + selectedText.length, - ); - handled = true; - pendingSurroundSelectionRef.current = null; - }); - - return handled; - }); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (pendingDeadKeySelectionRef.current) { - if (event.key === "Dead" || event.key === " " || event.code === "Space") { - return; - } - pendingDeadKeySelectionRef.current = null; - } - - if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey) { - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - return; - } - - editor.getEditorState().read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || selection.isCollapsed()) { - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - return; - } - if ($selectionTouchesInlineToken(selection)) { - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - return; - } - const range = getSelectionRangeForExpandedComposerOffsets(selection); - if (!range || range.start === range.end) { - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - return; - } - const value = $getRoot().getTextContent(); - if (selectionTouchesMentionBoundary(value, range.start, range.end)) { - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - return; - } - const snapshot = { - value, - expandedStart: range.start, - expandedEnd: range.end, - }; - pendingSurroundSelectionRef.current = snapshot; - pendingDeadKeySelectionRef.current = null; - }); - }; - - const onBeforeInput = (event: InputEvent) => { - if ( - event.inputType === "insertCompositionText" && - event.data === "`" && - BACKTICK_SURROUND_CLOSE_SYMBOL !== null && - pendingSurroundSelectionRef.current - ) { - pendingDeadKeySelectionRef.current = pendingSurroundSelectionRef.current; - return; - } - - if (pendingDeadKeySelectionRef.current) { - return; - } - - if (event.inputType === "insertCompositionText") { - return; - } - - if (typeof event.data !== "string") { - pendingSurroundSelectionRef.current = null; - return; - } - const inputData = event.inputType === "insertText" ? event.data : null; - if (!inputData || inputData.length !== 1) { - pendingSurroundSelectionRef.current = null; - return; - } - if (!applySurroundInsertion(inputData)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - }; - - const tryApplyDeadKeyBacktickSurround = (options?: { finalAttempt?: boolean }) => { - queueMicrotask(() => { - editor.update( - () => { - const pendingDeadKeySelection = pendingDeadKeySelectionRef.current; - if (!pendingDeadKeySelection) { - return; - } - - const currentValue = $getRoot().getTextContent(); - const backtickCloseSymbol = BACKTICK_SURROUND_CLOSE_SYMBOL; - if (backtickCloseSymbol === null) { - pendingDeadKeySelectionRef.current = null; - return; - } - - const expectedResolvedValue = `${pendingDeadKeySelection.value.slice(0, pendingDeadKeySelection.expandedStart)}\`${pendingDeadKeySelection.value.slice(pendingDeadKeySelection.expandedEnd)}`; - if (currentValue !== expectedResolvedValue) { - if (options?.finalAttempt) { - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - } - return; - } - - const selectedText = pendingDeadKeySelection.value.slice( - pendingDeadKeySelection.expandedStart, - pendingDeadKeySelection.expandedEnd, - ); - const replacementStart = collapseExpandedComposerCursor( - currentValue, - pendingDeadKeySelection.expandedStart, - ); - $setSelectionRangeAtComposerOffsets(replacementStart, replacementStart + 1); - const replacementSelection = $getSelection(); - if (!$isRangeSelection(replacementSelection)) { - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - return; - } - replacementSelection.insertText(`\`${selectedText}${backtickCloseSymbol}`); - $setSelectionRangeAtComposerOffsets( - replacementStart + 1, - replacementStart + 1 + selectedText.length, - ); - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - }, - { tag: HISTORY_MERGE_TAG }, - ); - }); - }; - - const onInput = (event: Event) => { - const inputEvent = event as InputEvent; - if ( - inputEvent.inputType === "insertText" || - inputEvent.inputType === "insertCompositionText" - ) { - tryApplyDeadKeyBacktickSurround(); - } - }; - - const onCompositionEnd = () => { - tryApplyDeadKeyBacktickSurround({ finalAttempt: true }); - }; - - let activeRootElement: HTMLElement | null = null; - const unregisterRootListener = editor.registerRootListener((rootElement, prevRootElement) => { - prevRootElement?.removeEventListener("keydown", onKeyDown); - prevRootElement?.removeEventListener("beforeinput", onBeforeInput, true); - prevRootElement?.removeEventListener("input", onInput); - prevRootElement?.removeEventListener("compositionend", onCompositionEnd); - rootElement?.addEventListener("keydown", onKeyDown); - rootElement?.addEventListener("beforeinput", onBeforeInput, true); - rootElement?.addEventListener("input", onInput); - rootElement?.addEventListener("compositionend", onCompositionEnd); - activeRootElement = rootElement; - }); - - return () => { - if (activeRootElement) { - activeRootElement.removeEventListener("keydown", onKeyDown); - activeRootElement.removeEventListener("beforeinput", onBeforeInput, true); - activeRootElement.removeEventListener("input", onInput); - activeRootElement.removeEventListener("compositionend", onCompositionEnd); - } - unregisterRootListener(); - }; - }, [editor]); - - return null; -} - -function ComposerPromptEditorInner({ - value, - cursor, - contextRecords, - buildContextClipboardFragment, - importContextFragment, - skills, - disabled, - placeholder, - containerClassName, - className, - placeholderClassName, - onChange, - onVisibleSelectionChange, - onCommandKeyDown, - onPageScrollKeyDown, - onPageScrollKeyUp, - onPageScrollRelease, - onCitationSubmitAndSend, - onPaste, - editorRef, -}: ComposerPromptEditorProps) { - const [editor] = useLexicalComposerContext(); - const onChangeRef = useRef(onChange); - const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); - const initialCursor = clampCollapsedComposerCursor(value, cursor); - const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor); - const skillsSignature = skillSignature(skills); - const skillsSignatureRef = useRef(skillsSignature); - const skillMetadataRef = useRef(skillMetadataByName(skills)); - const snapshotRef = useRef({ - value, - cursor: initialCursor, - expandedCursor: initialExpandedCursor, - contextIds: collectInlineContextIds(value), - }); - const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor }); - const isApplyingControlledUpdateRef = useRef(false); - // Latest controlled value, readable from editor listeners that fire before the layout - // effect has rewritten the editor to match it. - const latestValueRef = useRef(value); - useLayoutEffect(() => { - latestValueRef.current = value; - }, [value]); - const citationCommentRequestRef = useRef(null); - const [openCitationComment, setOpenCitationComment] = - useState(null); - const citationCommentActions = useMemo( - () => ({ - openComment: openCitationComment, - onOpenChange: (nodeKey: NodeKey, open: boolean) => { - setOpenCitationComment((current) => - open ? { nodeKey } : current?.nodeKey === nodeKey ? null : current, - ); - }, - onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), - }), - [onCitationSubmitAndSend, openCitationComment], - ); - - useEffect(() => { - onChangeRef.current = onChange; - }, [onChange]); - - useEffect(() => { - onVisibleSelectionChangeRef.current = onVisibleSelectionChange; - }, [onVisibleSelectionChange]); - - useLayoutEffect(() => { - skillMetadataRef.current = skillMetadataByName(skills); - }, [skills]); - - useEffect(() => { - editor.setEditable(!disabled); - }, [disabled, editor]); - - useEffect(() => { - const openCitationNodeKey = openCitationComment?.nodeKey; - if (!openCitationNodeKey) return; - return editor.registerUpdateListener(({ editorState }) => { - const isAttached = editorState.read(() => { - const node = $getNodeByKey(openCitationNodeKey); - return node instanceof ComposerCitationNode && node.isAttached(); - }); - if (!isAttached) { - setOpenCitationComment((current) => - current?.nodeKey === openCitationNodeKey ? null : current, - ); - } - }); - }, [editor, openCitationComment?.nodeKey]); - - useLayoutEffect(() => { - const normalizedCursor = clampCollapsedComposerCursor(value, cursor); - const previousSnapshot = snapshotRef.current; - const skillsChanged = skillsSignatureRef.current !== skillsSignature; - if ( - previousSnapshot.value === value && - previousSnapshot.cursor === normalizedCursor && - !skillsChanged - ) { - return; - } - - const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor); - snapshotRef.current = { - value, - cursor: normalizedCursor, - expandedCursor: normalizedExpandedCursor, - contextIds: collectInlineContextIds(value), - }; - selectionRangeRef.current = { - start: normalizedExpandedCursor, - end: normalizedExpandedCursor, - }; - skillsSignatureRef.current = skillsSignature; - - const rootElement = editor.getRootElement(); - const isFocused = Boolean(rootElement && document.activeElement === rootElement); - if (previousSnapshot.value === value && !skillsChanged && !isFocused) { - return; - } - - isApplyingControlledUpdateRef.current = true; - const isCiteInsertion = citationCommentRequestRef.current?.value === value; - let citationToOpen: ComposerCitationCommentTarget | null = null; - editor.update( - () => { - const shouldRewriteEditorState = previousSnapshot.value !== value || skillsChanged; - if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, skillMetadataRef.current); - } - if (shouldRewriteEditorState || isFocused) { - $setSelectionAtComposerOffset(normalizedCursor); - } - citationToOpen = $consumeComposerCitationCommentRequest(citationCommentRequestRef); - }, - { - ...(isCiteInsertion ? { tag: [HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG] } : {}), - onUpdate: () => { - if (citationToOpen) setOpenCitationComment(citationToOpen); - }, - }, - ); - queueMicrotask(() => { - isApplyingControlledUpdateRef.current = false; - }); - }, [cursor, editor, skillsSignature, value]); - - const focusAt = useCallback( - (nextCursor: number) => { - const rootElement = editor.getRootElement(); - if (!rootElement) return; - rootElement.focus({ preventScroll: true }); - // A newer prompt is waiting to be applied (a chip was just inserted through the store). - // Reporting the editor's stale text now would overwrite that prompt; the pending rewrite - // places the caret from the store's cursor instead. - if (snapshotRef.current.value !== latestValueRef.current) return; - const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); - editor.update(() => { - $setSelectionAtComposerOffset(boundedCursor); - }); - if (boundedCursor === snapshotRef.current.cursor) return; - snapshotRef.current = { - value: snapshotRef.current.value, - cursor: boundedCursor, - expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), - contextIds: snapshotRef.current.contextIds, - }; - selectionRangeRef.current = { - start: snapshotRef.current.expandedCursor, - end: snapshotRef.current.expandedCursor, - }; - onChangeRef.current( - snapshotRef.current.value, - boundedCursor, - snapshotRef.current.expandedCursor, - false, - snapshotRef.current.contextIds, - ); - }, - [editor], - ); - - const readSnapshot = useCallback((): { - value: string; - cursor: number; - expandedCursor: number; - contextIds: string[]; - } => { - let snapshot = snapshotRef.current; - editor.getEditorState().read(() => { - const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); - const nextCursor = clampCollapsedComposerCursor( - nextValue, - $readSelectionOffsetFromEditorState(fallbackCursor), - ); - const fallbackExpandedCursor = clampExpandedCursor( - nextValue, - snapshotRef.current.expandedCursor, - ); - const nextExpandedCursor = clampExpandedCursor( - nextValue, - $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), - ); - const selectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection()); - const contextIds = collectContextIds($getRoot()); - snapshot = { - value: nextValue, - cursor: nextCursor, - expandedCursor: nextExpandedCursor, - contextIds, - }; - selectionRangeRef.current = selectionRange ?? { - start: nextExpandedCursor, - end: nextExpandedCursor, - }; - }); - snapshotRef.current = snapshot; - return snapshot; - }, [editor]); - - useImperativeHandle( - editorRef, - () => ({ - focus: () => { - focusAt(snapshotRef.current.cursor); - }, - focusAt, - focusAtEnd: () => { - focusAt( - collapseExpandedComposerCursor( - snapshotRef.current.value, - snapshotRef.current.value.length, - ), - ); - }, - readSelectionRange: () => { - readSnapshot(); - return selectionRangeRef.current; - }, - requestCitationComment: (request) => { - citationCommentRequestRef.current = request; - const target = editor - .getEditorState() - .read(() => $consumeComposerCitationCommentRequest(citationCommentRequestRef)); - if (target) setOpenCitationComment(target); - }, - readSnapshot, - isCaretOnVisualEdge: (edge) => { - const snapshot = readSnapshot(); - if (snapshot.value.length === 0) return true; - const beforeCaret = snapshot.value.slice(0, snapshot.expandedCursor); - const afterCaret = snapshot.value.slice(snapshot.expandedCursor); - if (edge === "start" ? beforeCaret.includes("\n") : afterCaret.includes("\n")) { - return false; - } - const rootElement = editor.getRootElement(); - const selection = window.getSelection(); - if ( - !rootElement || - !selection || - !selection.isCollapsed || - selection.rangeCount === 0 || - !selection.anchorNode || - !rootElement.contains(selection.anchorNode) - ) { - return false; - } - const caretRect = caretLineRect(selection.getRangeAt(0), edge); - if (!caretRect) return false; - const edgeElement = - edge === "start" ? rootElement.firstElementChild : rootElement.lastElementChild; - const edgeRect = (edgeElement ?? rootElement).getBoundingClientRect(); - const threshold = caretRect.height / 2; - return edge === "start" - ? caretRect.top - edgeRect.top < threshold - : edgeRect.bottom - caretRect.bottom < threshold; - }, - }), - [editor, focusAt, readSnapshot], - ); - - const handleEditorChange = useCallback((editorState: EditorState) => { - editorState.read(() => { - const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); - const nextCursor = clampCollapsedComposerCursor( - nextValue, - $readSelectionOffsetFromEditorState(fallbackCursor), - ); - const fallbackExpandedCursor = clampExpandedCursor( - nextValue, - snapshotRef.current.expandedCursor, - ); - const nextExpandedCursor = clampExpandedCursor( - nextValue, - $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), - ); - const nextSelectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection()); - const previousSelectionRange = selectionRangeRef.current; - selectionRangeRef.current = nextSelectionRange ?? { - start: nextExpandedCursor, - end: nextExpandedCursor, - }; - const contextIds = collectContextIds($getRoot()); - const previousSnapshot = snapshotRef.current; - const snapshotChanged = !( - previousSnapshot.value === nextValue && - previousSnapshot.cursor === nextCursor && - previousSnapshot.expandedCursor === nextExpandedCursor && - previousSnapshot.contextIds.length === contextIds.length && - previousSnapshot.contextIds.every((id, index) => id === contextIds[index]) - ); - if (isApplyingControlledUpdateRef.current) { - return; - } - if (!snapshotChanged) { - if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { - onVisibleSelectionChangeRef.current?.(); - } - return; - } - // A selection-only update while a newer prompt waits to be applied (an attachment - // chip was just inserted through the store) would report stale text and stale - // context ids, clobbering the prompt and dropping the record. Let the rewrite land. - if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) { - return; - } - snapshotRef.current = { - value: nextValue, - cursor: nextCursor, - expandedCursor: nextExpandedCursor, - contextIds, - }; - const cursorAdjacentToMention = - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); - onChangeRef.current( - nextValue, - nextCursor, - nextExpandedCursor, - cursorAdjacentToMention, - contextIds, - ); - }); - }, []); - - return ( - - -
- } - onKeyDown={(event) => { - if ( - event.key === "Control" || - event.key === "Meta" || - event.key === "Alt" || - event.key === "Shift" - ) { - onPageScrollRelease?.(); - } - - if (event.key !== "PageUp" && event.key !== "PageDown") { - return; - } - - const pageScrollKey = getTimelinePageScrollKey({ - altKey: event.altKey, - clientHeight: event.currentTarget.clientHeight, - ctrlKey: event.ctrlKey, - defaultPrevented: event.defaultPrevented, - isComposing: event.nativeEvent.isComposing, - key: event.key, - keyCode: event.keyCode, - metaKey: event.metaKey, - scrollHeight: event.currentTarget.scrollHeight, - scrollTop: event.currentTarget.scrollTop, - shiftKey: event.shiftKey, - }); - if (!pageScrollKey) { - onPageScrollRelease?.(); - return; - } - if (!onPageScrollKeyDown) { - return; - } - - event.preventDefault(); - onPageScrollKeyDown(pageScrollKey); - }} - onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} - onBlur={onPageScrollRelease} - onPasteCapture={onPaste} - /> - } - placeholder={ - contextRecords.size > 0 ? null : ( -
- {placeholder} -
- ) - } - ErrorBoundary={LexicalErrorBoundary} - /> - - - - - - - - - - - -
-
-
- ); -} - -export function ComposerPromptEditor({ - value, - cursor, - contextRecords, - buildContextClipboardFragment, - importContextFragment, - skills, - disabled, - placeholder, - containerClassName, - className, - placeholderClassName, - onChange, - onVisibleSelectionChange, - onCommandKeyDown, - onPageScrollKeyDown, - onPageScrollKeyUp, - onPageScrollRelease, - onCitationSubmitAndSend, - onPaste, - editorRef, -}: ComposerPromptEditorProps) { - const initialValueRef = useRef(value); - const initialSkillMetadataRef = useRef(skillMetadataByName(skills)); - const initialConfig = useMemo( - () => ({ - namespace: "t3tools-composer-editor", - editable: true, - nodes: [ - ComposerMentionNode, - ComposerSkillNode, - ComposerCitationNode, - ComposerContextReferenceNode, - ], - editorState: () => { - $setComposerEditorPrompt(initialValueRef.current, initialSkillMetadataRef.current); - }, - onError: (error) => { - throw error; - }, - }), - [], - ); - - return ( - - - - - - ); +export function ComposerPromptEditor(props: ComposerPromptEditorProps) { + return ; } diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx new file mode 100644 index 000000000000..5126246dd05c --- /dev/null +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -0,0 +1,1379 @@ +import { Extension, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; +import { TaskList } from "@tiptap/extension-task-list"; +import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { splitBlockKeepMarks } from "@tiptap/pm/commands"; +import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import type { + AssistantCitation, + ComposerContextClipboardFragment, + ServerProviderSkill, +} from "@t3tools/contracts"; +import { + serializeAssistantCitation, + withAssistantCitationComment, +} from "@t3tools/shared/assistantCitations"; +import { + COMPOSER_CONTEXT_CLIPBOARD_MIME, + encodeComposerContextClipboardHtml, +} from "@t3tools/shared/composerContextClipboard"; +import { + createContext, + use, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { EditorContent, useEditor } from "@tiptap/react"; + +import { + clampCollapsedComposerCursor, + collapseExpandedComposerCursor, + expandCollapsedComposerCursor, + isCollapsedCursorAdjacentToInlineToken, +} from "~/composer-logic"; +import { + collectComposerPromptInlineTokens, + selectionTouchesMentionBoundary, +} from "~/composer-editor-mentions"; +import { + buildDocJson, + buildTiptapContent, + collapsedToFlat, + ComposerTaskItemExtension, + flatToCollapsed, + flatToMarkdown, + flatToPm, + pmToFlat, + serializeEditorDoc, + type SkillMeta, +} from "~/composer-rich-text-doc"; +import { collectInlineContextIds } from "~/lib/composerContextReferences"; +import { cn, isMacPlatform } from "~/lib/utils"; +import { basenameOfPath } from "~/pierre-icons"; +import { + COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME, + COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, + COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, + COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, + SKILL_CHIP_ICON_SVG, +} from "./composerInlineChip"; +import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; +import { AssistantCitationChip } from "./chat/AssistantCitationChip"; +import { getTimelinePageScrollKey } from "./chat/pageScrollController"; +import { ContextChipPopover } from "./contextChipParts"; +import { Button } from "./ui/button"; +import { + ComposerContextActionsContext, + ComposerContextReferenceChip, + ComposerContextRecordsContext, +} from "./composerContextPresentation"; +import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; +import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { importPastedComposerText } from "./composerInlineTokenPaste"; +import { didComposerSelectionChangeVisibly } from "./composerSelection"; +import type { ComposerDraftContextRecords } from "./composerContextPresentation"; + +export interface ComposerPromptEditorHandle { + focus: () => void; + focusAt: (cursor: number) => void; + focusAtEnd: () => void; + readSelectionRange: () => { start: number; end: number }; + requestCitationComment: (request: ComposerCitationCommentRequest) => void; + readSnapshot: () => { + value: string; + cursor: number; + expandedCursor: number; + contextIds: string[]; + }; + /** + * True when a collapsed caret sits on the first ("start") or last ("end") + * visual line, counting soft wraps. Prompt history only claims ArrowUp and + * ArrowDown at these edges so arrows still move the caret inside multiline + * text. + */ + isCaretOnVisualEdge: (edge: "start" | "end") => boolean; +} + +export interface ComposerPromptEditorProps { + value: string; + cursor: number; + /** + * Render Markdown styling (bold, italic, code, strike, task checkboxes). + * Off renders the same Tiptap engine as plain text: every marker stays a + * literal character. + */ + richTextEnabled?: boolean; + /** Draft records behind the prompt's context references, keyed by context id. */ + contextRecords: ComposerDraftContextRecords; + /** Structured clipboard payload for the given referenced ids, or null to skip. */ + buildContextClipboardFragment?: + | ((contextIds: ReadonlyArray) => string | null) + | undefined; + /** Imports a structured paste's records; returns ids that changed. */ + importContextFragment?: + | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap) + | undefined; + skills: ReadonlyArray; + disabled: boolean; + placeholder: string; + containerClassName?: string; + className?: string; + placeholderClassName?: string; + onChange: ( + nextValue: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + contextIds: string[], + ) => void; + onVisibleSelectionChange?: () => void; + onCommandKeyDown?: ( + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + event: KeyboardEvent, + isTaskItem?: boolean, + ) => boolean; + onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; + onPageScrollKeyUp?: (key: string) => void; + onPageScrollRelease?: () => void; + onCitationSubmitAndSend?: () => void; + onPaste: React.ClipboardEventHandler; + editorRef: React.RefObject; +} + +export type ComposerCitationCommentRequest = { + previousValue: string; + value: string; + citationStart: number; + sourceAnchor: AssistantCitationSourceAnchor; +}; + +type OpenCitationComment = { + key: string; + sourceAnchor?: AssistantCitationSourceAnchor; + removeOnCancel?: boolean; +}; + +const ComposerCitationCommentContext = createContext<{ + openComment: OpenCitationComment | null; + onOpenChange: (citeKey: string, open: boolean) => void; + onSubmitAndSend: () => void; +}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} }); + +const RichComposerSkillsContext = createContext>([]); + +const SURROUND_CLOSE: Record = { + "(": ")", + "[": "]", + "{": "}", + "'": "'", + '"': '"', + "“": "”", + "`": "`", + "<": ">", + "«": "»", + "*": "*", + _: "_", +}; + +function resolvedThemeFromDocument(): "light" | "dark" { + return document.documentElement.classList.contains("dark") ? "dark" : "light"; +} + +// ── Inline atom nodes (chips) ───────────────────────────────────────────── + +const ComposerMentionExtension = Node.create({ + name: "composer-mention", + group: "inline", + inline: true, + atom: true, + selectable: true, + addAttributes() { + return { + path: { default: "" }, + source: { default: "" }, + }; + }, + parseHTML() { + return [{ tag: "span[data-composer-mention]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["span", { "data-composer-mention": "", ...HTMLAttributes }]; + }, + addNodeView() { + return ReactNodeViewRenderer(ComposerMentionNodeView); + }, +}); + +function ComposerMentionNodeView({ node }: NodeViewProps) { + const actions = use(ComposerContextActionsContext); + const path = (node.attrs.path as string) ?? ""; + const chip = ( + + ); + return ( + + + + + {path} + + + + ); +} + +const ComposerSkillExtension = Node.create({ + name: "composer-skill", + group: "inline", + inline: true, + atom: true, + selectable: true, + addAttributes() { + return { + skillName: { default: "" }, + skillLabel: { default: "" }, + skillDescription: { default: null }, + }; + }, + parseHTML() { + return [{ tag: "span[data-composer-skill]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["span", { "data-composer-skill": "", ...HTMLAttributes }]; + }, + addNodeView() { + return ReactNodeViewRenderer(ComposerSkillNodeView); + }, +}); + +function ComposerSkillNodeView({ node }: NodeViewProps) { + const actions = use(ComposerContextActionsContext); + const skills = use(RichComposerSkillsContext); + const skillName = (node.attrs.skillName as string) ?? ""; + const skillLabel = (node.attrs.skillLabel as string) || skillName; + const skillDescription = (node.attrs.skillDescription as string | null) ?? null; + const skill = skills.find((candidate) => candidate.name === skillName); + return ( + + + + + ); +} + +const ComposerCitationExtension = Node.create({ + name: "composer-citation", + group: "inline", + inline: true, + atom: true, + selectable: true, + addAttributes() { + return { + citation: { default: null }, + source: { default: "" }, + citeKey: { default: "" }, + }; + }, + parseHTML() { + return [{ tag: "span[data-composer-citation]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["span", { "data-composer-citation": "", ...HTMLAttributes }]; + }, + addNodeView() { + return ReactNodeViewRenderer(ComposerCitationNodeView); + }, +}); + +function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { + const commentContext = use(ComposerCitationCommentContext); + const citation = node.attrs.citation as AssistantCitation; + const citeKey = node.attrs.citeKey as string; + const commentTarget = + commentContext.openComment?.key === citeKey ? commentContext.openComment : null; + + const nodePos = useCallback(() => { + const pos = typeof getPos === "function" ? getPos() : null; + return typeof pos === "number" ? pos : null; + }, [getPos]); + + const onSaveComment = useCallback( + (comment: string): boolean => { + if (!editor.isEditable) return false; + const pos = nodePos(); + if (pos === null) return false; + const current = editor.state.doc.nodeAt(pos); + if (!current || current.type.name !== "composer-citation") return false; + const currentCitation = current.attrs.citation as AssistantCitation; + const next = withAssistantCitationComment(currentCitation, comment); + const tr = editor.state.tr.setNodeMarkup(pos, undefined, { + ...current.attrs, + citation: next, + source: serializeAssistantCitation(next), + }); + editor.view.dispatch(tr); + return true; + }, + [editor, nodePos], + ); + + const onRemove = useCallback(() => { + if (!editor.isEditable) return; + const pos = nodePos(); + if (pos === null) return; + const current = editor.state.doc.nodeAt(pos); + if (!current) return; + editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + current.nodeSize }) + .run(); + }, [editor, nodePos]); + + return ( + + { + if (open && !editor.isEditable) return; + commentContext.onOpenChange(citeKey, open); + }, + ...(commentTarget?.removeOnCancel ? { onCancel: onRemove } : {}), + onSave: onSaveComment, + onSaveAndSend: (comment) => { + if (!onSaveComment(comment)) return false; + commentContext.onSubmitAndSend(); + return true; + }, + }} + /> + + ); +} + +const ComposerContextReferenceExtension = Node.create({ + name: "composer-context-reference", + group: "inline", + inline: true, + atom: true, + selectable: true, + addAttributes() { + return { + kind: { default: "" }, + contextId: { default: "" }, + label: { default: "" }, + source: { default: "" }, + }; + }, + parseHTML() { + return [{ tag: "span[data-composer-context-reference]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["span", { "data-composer-context-reference": "", ...HTMLAttributes }]; + }, + addNodeView() { + return ReactNodeViewRenderer(ComposerContextReferenceNodeView); + }, +}); + +function ComposerContextReferenceNodeView({ node }: NodeViewProps) { + return ( + + + + ); +} + +// ── Marker reveal (show ** when the cursor is on styled text) ────────────── + +type StyledRange = { + from: number; + to: number; + markers: { at: number; side: number; text: string }[]; +}; + +function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { + const ranges: StyledRange[] = []; + const map = serializeEditorDoc(doc); + let range: StyledRange | null = null; + let openLength = 0; + for (const run of map.runs) { + if (run.openLen > 0) { + range ??= { from: run.pmPos, to: run.pmPos, markers: [] }; + range.markers.push({ + at: run.pmPos, + side: -1, + text: map.value.slice(run.mdStart, run.mdStart + run.openLen), + }); + } + if (range === null) continue; + range.to = run.pmPos + run.docLen; + if (run.closeLen > 0) { + const end = run.mdStart + run.mdLen; + range.markers.push({ + at: range.to, + side: -2, + text: map.value.slice(end - run.closeLen, end), + }); + } + openLength += run.openLen - run.closeLen; + if (openLength === 0) { + ranges.push(range); + range = null; + } + } + return ranges; +} + +const MarkerPluginKey = new PluginKey("composer-rich-markers"); + +const ComposerMarkerPlugin = new Plugin({ + key: MarkerPluginKey, + state: { + init: (_, state) => decorationsForSelection(state.doc, state.selection), + apply: (tr, old) => + tr.docChanged || tr.selectionSet ? decorationsForSelection(tr.doc, tr.selection) : old, + }, + props: { + decorations(state) { + return MarkerPluginKey.getState(state); + }, + }, +}); + +function decorationsForSelection( + doc: ProseMirrorNode, + selection: { from: number; to: number; empty: boolean }, +): DecorationSet { + const decorations: Decoration[] = []; + if (!selection.empty) { + doc.nodesBetween(selection.from, selection.to, (node, pos) => { + if (node.type.name.startsWith("composer-")) { + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { class: "composer-chip-range-selected" }), + ); + return false; + } + return true; + }); + } + for (const range of collectStyledRanges(doc)) { + const active = selection.empty + ? selection.from >= range.from && selection.from <= range.to + : selection.from < range.to && selection.to > range.from; + if (!active) continue; + for (const { at, side, text } of range.markers) { + const marker = document.createElement("span"); + marker.className = "composer-rich-marker"; + marker.textContent = text; + marker.setAttribute("aria-hidden", "true"); + decorations.push( + Decoration.widget(at, marker, { side, key: `marker-${at}-${side}-${marker.textContent}` }), + ); + } + } + return DecorationSet.create(doc, decorations); +} + +const ComposerMarkersExtension = Extension.create({ + name: "composer-rich-markers", + addProseMirrorPlugins() { + return [ComposerMarkerPlugin]; + }, +}); + +// Document model (markdown ⇄ ProseMirror) lives in ~/composer-rich-text-doc so +// unit tests can round-trip it without a browser. +// ── Editor component ─────────────────────────────────────────────────────── + +type TiptapEditor = NonNullable>; + +export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { + // Extensions are creation-time: flipping the setting remounts the editor. + // Both halves initialize from the controlled Markdown value, so the draft + // survives the flip. + return ( + + ); +} + +function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { + const { + value, + cursor, + richTextEnabled, + contextRecords, + buildContextClipboardFragment, + importContextFragment, + skills, + disabled, + placeholder, + containerClassName, + className, + placeholderClassName, + onChange, + onVisibleSelectionChange, + onCommandKeyDown, + onPageScrollKeyDown, + onPageScrollKeyUp, + onPageScrollRelease, + onCitationSubmitAndSend, + onPaste, + editorRef, + } = props; + // The setting toggles styling, not the engine: both modes are Tiptap. + // Plain mode disables the mark extensions, so markers stay literal text. + const richText = richTextEnabled ?? false; + + const onChangeRef = useRef(onChange); + const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); + const onCommandKeyDownRef = useRef(onCommandKeyDown); + const buildFragmentRef = useRef(buildContextClipboardFragment); + const importFragmentRef = useRef(importContextFragment); + const skillsRef = useRef(skills); + const latestValueRef = useRef(value); + // The editor instance for callbacks created before it exists (paste). + // Effects flush before any user interaction, so this is always set. + const editorHolder = useRef(null); + + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + useEffect(() => { + onVisibleSelectionChangeRef.current = onVisibleSelectionChange; + }, [onVisibleSelectionChange]); + useEffect(() => { + onCommandKeyDownRef.current = onCommandKeyDown; + }, [onCommandKeyDown]); + useEffect(() => { + buildFragmentRef.current = buildContextClipboardFragment; + }, [buildContextClipboardFragment]); + useEffect(() => { + importFragmentRef.current = importContextFragment; + }, [importContextFragment]); + useEffect(() => { + skillsRef.current = skills; + }, [skills]); + useLayoutEffect(() => { + latestValueRef.current = value; + }, [value]); + + const skillLabelFor = useCallback((name: string): SkillMeta => { + const normalized = name.startsWith("$") ? name.slice(1) : name; + const skill = skillsRef.current.find((candidate) => candidate.name === normalized); + if (!skill) { + return { label: formatProviderSkillDisplayName({ name: normalized }), description: null }; + } + const shortDescription = skill.shortDescription?.trim(); + return { + label: formatProviderSkillDisplayName(skill), + description: shortDescription || skill.description?.trim() || null, + }; + }, []); + + const initialCursor = clampCollapsedComposerCursor(value, cursor); + const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor); + const snapshotRef = useRef({ + value, + cursor: initialCursor, + expandedCursor: initialExpandedCursor, + contextIds: collectInlineContextIds(value), + }); + const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor }); + const isApplyingControlledUpdateRef = useRef(false); + const hasAppliedControlledSelectionRef = useRef(false); + const citationRequestRef = useRef(null); + const [openCitation, setOpenCitation] = useState(null); + const [isEmpty, setIsEmpty] = useState(value.length === 0); + + const citationCommentActions = useMemo( + () => ({ + openComment: openCitation, + onOpenChange: (nodeKey: string, open: boolean) => { + setOpenCitation((current) => { + if (open) { + if (current?.key === nodeKey) return current; + return { key: nodeKey }; + } + return current?.key === nodeKey ? null : current; + }); + }, + onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), + }), + [onCitationSubmitAndSend, openCitation], + ); + + const handleEditorChange = useCallback((updated: TiptapEditor) => { + const map = serializeEditorDoc(updated.state.doc); + const { from, to } = updated.state.selection; + const fromFlat = pmToFlat(map, from); + const toFlat = pmToFlat(map, to); + const nextValue = map.value; + const nextCursor = clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat)); + const nextExpandedCursor = Math.max( + 0, + Math.min(map.value.length, flatToMarkdown(map, fromFlat)), + ); + const nextSelectionRange = { + start: Math.min(nextExpandedCursor, flatToMarkdown(map, toFlat)), + end: Math.max(nextExpandedCursor, flatToMarkdown(map, toFlat)), + }; + const previousSelectionRange = selectionRangeRef.current; + selectionRangeRef.current = nextSelectionRange; + setIsEmpty(nextValue.length === 0); + const previousSnapshot = snapshotRef.current; + const snapshotChanged = !( + previousSnapshot.value === nextValue && + previousSnapshot.cursor === nextCursor && + previousSnapshot.expandedCursor === nextExpandedCursor && + previousSnapshot.contextIds.length === map.contextIds.length && + previousSnapshot.contextIds.every((id, index) => id === map.contextIds[index]) + ); + if (isApplyingControlledUpdateRef.current) return; + if (!snapshotChanged) { + if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { + onVisibleSelectionChangeRef.current?.(); + } + return; + } + // A selection-only update while a newer prompt waits to be applied (a chip + // was just inserted through the store) would report stale text and clobber + // the prompt. Let the controlled rewrite land instead. + if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) { + return; + } + snapshotRef.current = { + value: nextValue, + cursor: nextCursor, + expandedCursor: nextExpandedCursor, + contextIds: map.contextIds, + }; + const cursorAdjacentToMention = + isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || + isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); + onChangeRef.current( + nextValue, + nextCursor, + nextExpandedCursor, + cursorAdjacentToMention, + map.contextIds, + ); + }, []); + + const editor = useEditor( + { + extensions: [ + StarterKit.configure({ + blockquote: false, + bulletList: false, + codeBlock: false, + heading: false, + horizontalRule: false, + listItem: false, + link: false, + orderedList: false, + underline: false, + dropcursor: false, + gapcursor: false, + trailingNode: false, + // Plain mode has no marks: typed markers stay literal characters. + ...(richText ? {} : { bold: false, italic: false, strike: false, code: false }), + }), + ComposerMentionExtension, + ComposerSkillExtension, + ComposerCitationExtension, + ComposerContextReferenceExtension, + ComposerMarkersExtension, + ...(richText + ? [ + TaskList, + ComposerTaskItemExtension.extend({ + addInputRules() { + return [ + wrappingInputRule({ + find: /^- \[([ xX])\] $/, + type: this.type, + getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }), + }), + ]; + }, + }), + ] + : []), + ], + content: buildDocJson( + value, + (name) => { + const normalized = name.startsWith("$") ? name.slice(1) : name; + const found = skills.find((candidate) => candidate.name === normalized); + if (!found) { + return { + label: formatProviderSkillDisplayName({ name: normalized }), + description: null, + }; + } + const shortDescription = found.shortDescription?.trim(); + return { + label: formatProviderSkillDisplayName(found), + description: shortDescription || found.description?.trim() || null, + }; + }, + { styling: richText }, + ), + editable: !disabled, + editorProps: { + attributes: { + class: cn( + "composer-tiptap block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", + className, + ), + "data-testid": "composer-editor", + "data-composer-rich-text": richText ? "true" : "false", + "aria-placeholder": placeholder, + }, + handleKeyDown: (view, event) => { + if ( + isMacPlatform(navigator.platform) && + (event.key === "Home" || event.key === "End") && + !event.altKey && + !event.metaKey && + !event.ctrlKey && + !event.isComposing + ) { + const selection = window.getSelection(); + if ( + selection?.anchorNode && + view.dom.contains(selection.anchorNode) && + typeof selection.modify === "function" + ) { + event.preventDefault(); + event.stopPropagation(); + selection.modify( + event.shiftKey ? "extend" : "move", + event.key === "Home" ? "backward" : "forward", + "lineboundary", + ); + if (selection.anchorNode && selection.focusNode) { + view.dispatch( + view.state.tr + .setSelection( + TextSelection.create( + view.state.doc, + view.posAtDOM(selection.anchorNode, selection.anchorOffset), + view.posAtDOM(selection.focusNode, selection.focusOffset), + ), + ) + .scrollIntoView(), + ); + } + return true; + } + } + if ( + (event.key === "ArrowLeft" || event.key === "ArrowRight") && + !event.shiftKey && + !event.altKey && + !event.metaKey && + !event.ctrlKey && + !event.isComposing && + view.state.selection.empty + ) { + const { $from } = view.state.selection; + const direction = event.key === "ArrowLeft" ? -1 : 1; + const adjacent = direction === -1 ? $from.nodeBefore : $from.nodeAfter; + if (adjacent?.type.name.startsWith("composer-")) { + event.preventDefault(); + event.stopPropagation(); + view.dispatch( + view.state.tr + .setSelection( + TextSelection.create(view.state.doc, $from.pos + direction * adjacent.nodeSize), + ) + .scrollIntoView(), + ); + return true; + } + } + if (event.key === "Enter" && (event.isComposing || event.keyCode === 229)) { + event.stopPropagation(); + return true; + } + // Enter on a focused task checkbox must not send the prompt (or + // split anything): Space toggles it, Enter does nothing. + if (event.key === "Enter" && event.target instanceof HTMLInputElement) { + event.preventDefault(); + return true; + } + const handler = onCommandKeyDownRef.current; + if (event.key === "Enter") { + const instance = editorHolder.current; + const isTaskItem = richText && (instance?.isActive("taskItem") ?? false); + const handled = handler?.("Enter", event, isTaskItem) ?? false; + if (handled) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + event.preventDefault(); + if ( + isTaskItem && + instance && + (instance.commands.splitListItem("taskItem", { checked: false }) || + (view.state.selection.$from.parent.content.size === 0 && + instance.commands.liftListItem("taskItem"))) + ) { + return true; + } + // Split the paragraph so a single newline visibly advances the caret. + return splitBlockKeepMarks(view.state, (tr) => { + // The split is programmatic, so the browser won't follow the + // caret into view on its own. + view.dispatch(tr.scrollIntoView()); + }); + } + if (!handler) return false; + const key = + event.key === "Tab" + ? ("Tab" as const) + : event.key === "ArrowDown" + ? ("ArrowDown" as const) + : event.key === "ArrowUp" + ? ("ArrowUp" as const) + : null; + if (!key) return false; + const handled = handler(key, event); + if (handled) { + event.preventDefault(); + event.stopPropagation(); + } + return handled; + }, + handleTextInput: (view, from, to, text) => { + if (text.length !== 1) return false; + const closer = SURROUND_CLOSE[text]; + if (!closer || from === to) return false; + // Never wrap chips or other atoms, and never wrap styled text: the + // default replace keeps marks intact, wrapping would drop them. + let touchesSpecial = false; + view.state.doc.nodesBetween(from, to, (node) => { + if ( + (node.isAtom && node.isInline && !node.isText) || + (node.isText && node.marks.length > 0) + ) { + touchesSpecial = true; + return false; + } + return true; + }); + if (touchesSpecial) return false; + const map = serializeEditorDoc(view.state.doc); + const startMd = flatToMarkdown(map, pmToFlat(map, from)); + const endMd = flatToMarkdown(map, pmToFlat(map, to)); + if (selectionTouchesMentionBoundary(map.value, startMd, endMd)) return false; + const tr = view.state.tr.insertText(closer, to).insertText(text, from); + tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length)); + view.dispatch(tr); + return true; + }, + handlePaste: (view, event) => { + const clipboardData = event.clipboardData; + if (!clipboardData || clipboardData.files.length > 0) return false; + const pastedText = clipboardData.getData("text/plain"); + if (!pastedText) return false; + event.preventDefault(); + const importFragment = importFragmentRef.current; + let text = importFragment + ? importPastedComposerText(clipboardData, importFragment) + : pastedText; + // Complete chips at paste boundaries just as autocomplete does. + const tokens = collectComposerPromptInlineTokens(`${text}\n`); + const lastToken = tokens.at(-1); + if ( + (lastToken?.type === "mention" || lastToken?.type === "skill") && + lastToken.end === text.length + ) { + text += " "; + } + if ( + (tokens[0]?.type === "mention" || tokens[0]?.type === "skill") && + tokens[0].start === 0 + ) { + const map = serializeEditorDoc(view.state.doc); + const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); + if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; + } + const editorInstance = editorHolder.current; + if (editorInstance) { + insertMarkdownParagraphs(text, skillLabelFor, { styling: richText }, (content) => { + editorInstance.commands.insertContent(content); + }); + scrollTiptapCaretIntoView(editorInstance); + } + return true; + }, + }, + onUpdate: ({ editor: updated }) => { + handleEditorChange(updated); + }, + onSelectionUpdate: ({ editor: updated }) => { + handleEditorChange(updated); + }, + }, + [], + ); + + useEffect(() => { + editor?.setEditable(!disabled); + }, [disabled, editor]); + + useEffect(() => { + editorHolder.current = editor; + }, [editor]); + + const readSnapshot = useCallback(() => { + const snapshot = snapshotRef.current; + if (!editor) return snapshot; + const map = serializeEditorDoc(editor.state.doc); + const { from, to } = editor.state.selection; + const fromFlat = pmToFlat(map, from); + const next: typeof snapshot = { + value: map.value, + cursor: clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat)), + expandedCursor: Math.max(0, Math.min(map.value.length, flatToMarkdown(map, fromFlat))), + contextIds: map.contextIds, + }; + const toFlat = pmToFlat(map, to); + selectionRangeRef.current = { + start: Math.min(next.expandedCursor, flatToMarkdown(map, toFlat)), + end: Math.max(next.expandedCursor, flatToMarkdown(map, toFlat)), + }; + snapshotRef.current = next; + return next; + }, [editor]); + + // Controlled value/cursor from the store (history recall, chip insertion…). + useLayoutEffect(() => { + if (!editor) return; + const initialSelection = !hasAppliedControlledSelectionRef.current; + hasAppliedControlledSelectionRef.current = true; + const normalizedCursor = clampCollapsedComposerCursor(value, cursor); + const previousSnapshot = snapshotRef.current; + if ( + !initialSelection && + previousSnapshot.value === value && + previousSnapshot.cursor === normalizedCursor + ) { + return; + } + const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor); + snapshotRef.current = { + value, + cursor: normalizedCursor, + expandedCursor: normalizedExpandedCursor, + contextIds: collectInlineContextIds(value), + }; + selectionRangeRef.current = { + start: normalizedExpandedCursor, + end: normalizedExpandedCursor, + }; + setIsEmpty(value.length === 0); + const rootElement = editor.view.dom; + const isFocused = Boolean(rootElement && document.activeElement === rootElement); + if (!initialSelection && previousSnapshot.value === value && !isFocused) return; + + isApplyingControlledUpdateRef.current = true; + const pendingCitation = + citationRequestRef.current?.value === value ? citationRequestRef.current : null; + if (previousSnapshot.value !== value) { + editor.commands.setContent(buildDocJson(value, skillLabelFor, { styling: richText }), { + emitUpdate: false, + }); + } + const map = serializeEditorDoc(editor.state.doc); + const flat = collapsedToFlat(map, normalizedCursor); + editor.commands.setTextSelection(flatToPm(map, flat)); + if (isFocused) scrollTiptapCaretIntoView(editor); + if (pendingCitation) { + citationRequestRef.current = null; + const target = map.runs.find( + (run) => + run.kind === "token" && + run.nodeName === "composer-citation" && + run.mdStart === pendingCitation.citationStart, + ); + if (target) { + const node = editor.state.doc.nodeAt(target.pmPos); + const citeKey = (node?.attrs as { citeKey?: string } | undefined)?.citeKey; + if (citeKey) { + setOpenCitation({ + key: citeKey, + sourceAnchor: pendingCitation.sourceAnchor, + removeOnCancel: true, + }); + } + } + } + queueMicrotask(() => { + isApplyingControlledUpdateRef.current = false; + }); + }, [cursor, editor, richText, skillLabelFor, value]); + + const focusAt = useCallback( + (nextCursor: number) => { + if (!editor) return; + editor.view.dom.focus({ preventScroll: true }); + // A newer prompt is waiting to be applied (a chip was just inserted + // through the store). Reporting the editor's stale text now would + // overwrite that prompt; the pending rewrite places the caret instead. + if (snapshotRef.current.value !== latestValueRef.current) return; + const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); + const map = serializeEditorDoc(editor.state.doc); + const flat = collapsedToFlat(map, boundedCursor); + editor.commands.setTextSelection(flatToPm(map, flat)); + scrollTiptapCaretIntoView(editor); + if (boundedCursor === snapshotRef.current.cursor) return; + snapshotRef.current = { + value: snapshotRef.current.value, + cursor: boundedCursor, + expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), + contextIds: snapshotRef.current.contextIds, + }; + selectionRangeRef.current = { + start: snapshotRef.current.expandedCursor, + end: snapshotRef.current.expandedCursor, + }; + onChangeRef.current( + snapshotRef.current.value, + boundedCursor, + snapshotRef.current.expandedCursor, + false, + snapshotRef.current.contextIds, + ); + }, + [editor], + ); + + useImperativeHandle( + editorRef, + () => ({ + focus: () => { + focusAt(snapshotRef.current.cursor); + }, + focusAt, + focusAtEnd: () => { + focusAt( + collapseExpandedComposerCursor( + snapshotRef.current.value, + snapshotRef.current.value.length, + ), + ); + }, + readSelectionRange: () => { + readSnapshot(); + return selectionRangeRef.current; + }, + requestCitationComment: (request) => { + citationRequestRef.current = request; + if (!editor) return; + const map = serializeEditorDoc(editor.state.doc); + if (map.value !== request.value) return; + const target = map.runs.find( + (run) => + run.kind === "token" && + run.nodeName === "composer-citation" && + run.mdStart === request.citationStart, + ); + if (!target) return; + const node = editor.state.doc.nodeAt(target.pmPos); + const citeKey = (node?.attrs as { citeKey?: string } | undefined)?.citeKey; + if (citeKey) { + citationRequestRef.current = null; + setOpenCitation({ + key: citeKey, + sourceAnchor: request.sourceAnchor, + removeOnCancel: true, + }); + } + }, + readSnapshot, + isCaretOnVisualEdge: (edge) => { + const snapshot = readSnapshot(); + if (snapshot.value.length === 0) return true; + const beforeCaret = snapshot.value.slice(0, snapshot.expandedCursor); + const afterCaret = snapshot.value.slice(snapshot.expandedCursor); + if (edge === "start" ? beforeCaret.includes("\n") : afterCaret.includes("\n")) { + return false; + } + const rootElement = editor?.view.dom; + const selection = window.getSelection(); + if ( + !rootElement || + !selection || + !selection.isCollapsed || + selection.rangeCount === 0 || + !selection.anchorNode || + !rootElement.contains(selection.anchorNode) + ) { + return false; + } + const caretRect = caretLineRect(selection.getRangeAt(0), edge); + if (!caretRect) return false; + const edgeElement = + edge === "start" ? rootElement.firstElementChild : rootElement.lastElementChild; + const edgeRect = (edgeElement ?? rootElement).getBoundingClientRect(); + const threshold = caretRect.height / 2; + return edge === "start" + ? caretRect.top - edgeRect.top < threshold + : edgeRect.bottom - caretRect.bottom < threshold; + }, + }), + [editor, focusAt, readSnapshot], + ); + + const handleCopyCut = useCallback( + (event: React.ClipboardEvent, cut: boolean) => { + const build = buildFragmentRef.current; + if (!editor || (cut && !editor.isEditable)) return; + const clipboardData = event.clipboardData; + const { from, to } = editor.state.selection; + if (from === to) return; + const { doc, schema } = editor.state; + const slice = doc.slice(from, to); + const first = slice.content.firstChild; + const content = first?.isInline + ? schema.nodes.paragraph!.create(null, slice.content) + : first?.type.name === "taskItem" + ? schema.nodes.taskList!.create(null, slice.content) + : slice.content; + const text = serializeEditorDoc(doc.type.create(null, content)).value; + const contextIds = Array.from(new Set(collectInlineContextIds(text))); + const fragment = contextIds.length > 0 ? build?.(contextIds) : null; + event.preventDefault(); + clipboardData.setData("text/plain", text); + if (fragment) { + clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment); + clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment)); + } + if (cut) { + editor.chain().focus().deleteSelection().run(); + } + }, + [editor], + ); + + return ( + + + +
+ { + if ( + event.key === "Control" || + event.key === "Meta" || + event.key === "Alt" || + event.key === "Shift" + ) { + onPageScrollRelease?.(); + } + if (event.key !== "PageUp" && event.key !== "PageDown") return; + const target = event.currentTarget.querySelector( + '[data-testid="composer-editor"]', + ) as HTMLElement | null; + if (!target) return; + const pageScrollKey = getTimelinePageScrollKey({ + altKey: event.altKey, + clientHeight: target.clientHeight, + ctrlKey: event.ctrlKey, + defaultPrevented: event.defaultPrevented, + isComposing: event.nativeEvent.isComposing, + key: event.key, + keyCode: event.keyCode, + metaKey: event.metaKey, + scrollHeight: target.scrollHeight, + scrollTop: target.scrollTop, + shiftKey: event.shiftKey, + }); + if (!pageScrollKey) { + onPageScrollRelease?.(); + return; + } + if (!onPageScrollKeyDown) return; + event.preventDefault(); + onPageScrollKeyDown(pageScrollKey); + }} + onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} + onBlur={onPageScrollRelease} + onPasteCapture={onPaste} + onCopyCapture={(event) => handleCopyCut(event, false)} + onCutCapture={(event) => handleCopyCut(event, true)} + /> + {isEmpty && contextRecords.size === 0 && placeholder ? ( +
+ {placeholder} +
+ ) : null} +
+
+
+
+ ); +} + +/** + * Insert pasted markdown at the selection, rebuilding inline tokens as chips + * and styled spans as marks. + * + * Newlines always become paragraph splits — never trailing hard breaks, which + * render no visible line — so pasted text lands exactly as typed. + */ +function insertMarkdownParagraphs( + value: string, + skillLabelFor: (name: string) => SkillMeta, + options: { styling: boolean }, + insertContent: (content: JSONContent[] | JSONContent) => void, +): void { + const blocks = buildTiptapContent(value, skillLabelFor, options); + if (blocks.length === 1 && blocks[0]?.type === "paragraph") { + const inline = (blocks[0]?.content ?? []) as JSONContent[]; + if (inline.length === 0) return; + insertContent(inline); + return; + } + insertContent(blocks as JSONContent[]); +} + +/** + * Follow a programmatically placed caret: native scrolling only happens for + * real input, so controlled rewrites, pastes, and focus restores scroll the + * composer to the caret explicitly. + */ +function scrollTiptapCaretIntoView(editor: TiptapEditor): void { + editor.view.dispatch(editor.state.tr.scrollIntoView()); +} + +/** + * Client rect of the caret's visual line, so prompt history keeps claiming + * ArrowUp/Down only at the first and last soft-wrapped lines. + */ +function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null { + const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0); + const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0]; + if (collapsedRect) return collapsedRect; + + const container = range.startContainer; + // TEXT_NODE without importing the DOM lib's Node (shadowed by Tiptap's). + if (container.nodeType === 3) { + const textNode = container as Text; + if (textNode.data.length === 0) return null; + const probeStart = Math.max( + 0, + Math.min( + edge === "start" ? range.startOffset : range.startOffset - 1, + textNode.data.length - 1, + ), + ); + const probeRange = document.createRange(); + probeRange.setStart(textNode, probeStart); + probeRange.setEnd(textNode, probeStart + 1); + const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); + if (probeRect) return probeRect; + const boundingRect = probeRange.getBoundingClientRect(); + return boundingRect.height > 0 ? boundingRect : null; + } + + if (!(container instanceof HTMLElement)) return null; + const neighbour = + container.childNodes[Math.max(0, range.startOffset - 1)] ?? + container.childNodes[range.startOffset]; + if (neighbour instanceof HTMLElement) { + const neighbourRect = neighbour.getBoundingClientRect(); + if (neighbourRect.height > 0) return neighbourRect; + } else if (neighbour instanceof Text && neighbour.data.length > 0) { + const isBeforeCaret = neighbour === container.childNodes[range.startOffset - 1]; + const probeStart = isBeforeCaret ? neighbour.data.length - 1 : 0; + const probeRange = document.createRange(); + probeRange.setStart(neighbour, probeStart); + probeRange.setEnd(neighbour, probeStart + 1); + const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); + if (probeRect) return probeRect; + } + const containerRect = container.getBoundingClientRect(); + return containerRect.height > 0 ? containerRect : null; +} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 795ddf03d2ae..89518a6f829c 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -121,19 +121,16 @@ const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet = new Set(); interface DiffPanelProps { mode?: DiffPanelMode; composerDraftTarget: ScopedThreadRef | DraftId; - initialGitScope: "branch" | "unstaged"; workspaceMutationId: string | null; } export default function DiffPanel({ mode = "inline", composerDraftTarget, - initialGitScope: initialGitScopeProp, workspaceMutationId, }: DiffPanelProps) { const { resolvedTheme } = useTheme(); const settings = useClientSettings(); - const [initialGitScope] = useState(initialGitScopeProp); const diffLayout = settings.diffLayout; const updateClientSettings = useUpdateClientSettings(); const [wordWrap, setWordWrap] = useState(settings.wordWrap); @@ -187,11 +184,7 @@ export default function DiffPanel({ : null, ); const diffSelection = useDiffPanelStore((state) => - selectThreadDiffPanelSelection( - state.byThreadKey, - routeThreadRef, - initialGitScope === "unstaged", - ), + selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), ); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e3939ce1ae0a..8f91f08c7093 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -79,6 +79,7 @@ import { replaceTextRange, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; +import { listContinuationForEnter, listIndentForTab } from "../../composer-list-continuation"; import { deriveComposerSendState, getAntigravitySendBlockReason, @@ -244,6 +245,7 @@ import { import { useEnvironmentQuery } from "~/state/query"; import { useDebouncedValue } from "~/state/queries"; import { ProviderModelPicker } from "./ProviderModelPicker"; +import { resolveModelPickerSelectedModel } from "./ModelPickerContent"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; @@ -933,6 +935,7 @@ import { PaperclipIcon, PencilRulerIcon, PlayIcon, + ShieldIcon, XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; @@ -1265,6 +1268,7 @@ export interface ChatComposerHandle { selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; + multipleModelSelections: ReadonlyArray | null; providerAvailable: boolean; selectedProvider: ProviderDriverKind; selectedModel: string; @@ -1274,6 +1278,7 @@ export interface ChatComposerHandle { }; /** Validate the fully composed text immediately before a provider turn starts. */ validateProviderInput: (providerInput: string) => boolean; + setMultipleModelSelections: (selections: ReadonlyArray) => void; } // -------------------------------------------------------------------------- @@ -1290,6 +1295,11 @@ export interface ChatComposerProps { routeKind: "server" | "draft"; routeThreadRef: ScopedThreadRef; draftId: DraftId | null; + multipleModelSelections: ReadonlyArray | null; + supportsMultipleModels: boolean; + onMultipleModelSelectionsChange: React.Dispatch< + React.SetStateAction | null> + >; // Thread context activeThreadId: ThreadId | null; @@ -1418,7 +1428,11 @@ export interface ChatComposerProps { cursorAdjacentToMention: boolean, ) => void; - onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void; + onProviderModelSelect: ( + instanceId: ProviderInstanceId, + model: string, + options?: { focusComposer?: boolean }, + ) => void; onOpenProviderSetup: (instanceId: ProviderInstanceId) => void; getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null; toggleInteractionMode: () => void; @@ -1447,6 +1461,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) routeKind, routeThreadRef, draftId, + multipleModelSelections, + supportsMultipleModels, + onMultipleModelSelectionsChange: setMultipleModelSelections, activeThreadId, activeThreadEnvironmentId: _activeThreadEnvironmentId, activeThread, @@ -1845,7 +1862,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const selectedInstanceId = selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; - const noProviderAvailable = selectedProviderEntry === undefined; + const noProviderAvailable = + selectedProviderEntry === undefined && multipleModelSelections === null; // Before the catalog arrives, every thread resolves to "no provider". Send // stays blocked either way; only the chrome waits, keeping the picker with // the thread's own selection instead of swapping in the setup button and @@ -1881,9 +1899,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const sendDisabledReason = externalSendDisabledReason ?? + (multipleModelSelections?.length === 0 ? "Select at least one model." : null) ?? (activePendingProgress ? attachmentBlockReason - : (attachmentBlockReason ?? providerSendBlockReason)); + : (attachmentBlockReason ?? + (multipleModelSelections === null ? providerSendBlockReason : null))); const isSendDisabled = sendDisabledReason !== null; const selectedProviderStatus = useMemo( () => selectedProviderEntry?.snapshot ?? null, @@ -3263,7 +3283,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [setIsComposerScrollCollapsed]); /** - * Payloads for chips the prompt no longer references. Lexical's history restores the + * Payloads for chips the prompt no longer references. History undo restores the * reference text but knows nothing about the draft records behind it, so a delete keeps its * payload here and an undo puts it back rather than leaving a dangling chip. */ @@ -3419,6 +3439,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) replacement: string, options?: { expectedText?: string; + expandedCursorAfterReplace?: number; focusEditorAfterReplace?: boolean; citationComment?: { start: number; sourceAnchor: AssistantCitationSourceAnchor }; }, @@ -3439,7 +3460,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return false; } const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); - const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); + const nextCursor = collapseExpandedComposerCursor( + next.text, + options?.expandedCursorAfterReplace ?? next.cursor, + ); const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); if (options?.citationComment) { composerEditorRef.current?.requestCitationComment({ @@ -3926,6 +3950,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const onComposerCommandKey = ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, + isTaskItem = false, ) => { if (key === "Tab" && event.shiftKey) { if (!planModeUiEnabled) return false; @@ -3969,6 +3994,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) submitComposer(undefined, submissionIntent); return true; } + // Native task splitting preserves marks and chips on both sides of the caret. + if (key === "Enter" && isTaskItem) return false; + if (!event.isComposing && (key === "Enter" || (key === "Tab" && !event.shiftKey))) { + const selection = composerEditorRef.current?.readSelectionRange(); + const snapshot = readComposerSnapshot(); + if (selection && selection.start === selection.end && snapshot.value === promptRef.current) { + const edit = + key === "Enter" + ? listContinuationForEnter(snapshot.value, selection.start) + : listIndentForTab(snapshot.value, selection.start, selection.end); + if ( + edit && + applyPromptReplacement( + edit.start, + edit.end, + edit.replacement, + key === "Tab" + ? { expandedCursorAfterReplace: selection.start + edit.replacement.length } + : undefined, + ) + ) { + return true; + } + } + } return false; }; @@ -4911,7 +4961,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null} { + const current = multipleModelSelections ?? [selectedModelSelection]; + const matchesModel = (selection: ModelSelection) => { + if (selection.instanceId !== instanceId) return false; + const entry = providerInstanceEntries.find( + (entry) => entry.instanceId === selection.instanceId, + ); + const resolvedModel = resolveModelPickerSelectedModel({ + driverKind: entry?.driverKind, + model: selection.model, + options: modelOptionsByInstance.get(selection.instanceId) ?? [], + }); + return (resolvedModel?.slug ?? selection.model) === model; + }; + const exists = current.some(matchesModel); + const next = exists + ? current.filter((selection) => !matchesModel(selection)) + : [...current, createModelSelection(instanceId, model)]; + if (next.length > 1) { + setMultipleModelSelections(next); + } else { + setMultipleModelSelections(null); + const remaining = next[0] ?? selectedModelSelection; + onProviderModelSelect(remaining.instanceId, remaining.model, { + focusComposer: false, + }); + } + }, + } + : {})} activeInstanceId={ providerCatalogPending ? (activeThreadModelSelection?.instanceId ?? selectedInstanceId) @@ -4951,7 +5036,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : {})} onOpenChange={setIsComposerModelPickerOpen} getModelDisabledReason={getModelDisabledReason} - onInstanceModelChange={onProviderModelSelect} + onInstanceModelChange={(instanceId, model) => { + setMultipleModelSelections(null); + onProviderModelSelect(instanceId, model); + }} onOpenProviderSetup={onOpenProviderSetup} /> @@ -5870,13 +5958,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, - providerAvailable: !noProviderAvailable && providerSendBlockReason === null, + multipleModelSelections: + routeKind === "draft" && multipleModelSelections !== null + ? multipleModelSelections.map((selection) => + selection.instanceId === selectedModelSelection.instanceId && + selection.model === selectedModelSelection.model + ? selectedModelSelection + : selection, + ) + : null, + providerAvailable: + multipleModelSelections !== null || + (!noProviderAvailable && providerSendBlockReason === null), selectedProvider, selectedModel, selectedProviderModels, interactionMode, interactionModeEnabled: planModeUiEnabled, }), + setMultipleModelSelections, validateProviderInput: (providerInput: string) => { const validationMessage = getComposerSubmissionValidationMessage({ prompt: promptRef.current, @@ -5919,6 +6019,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedModelOptionsForDispatch, selectedModelSelection, + multipleModelSelections, + setMultipleModelSelections, + routeKind, noProviderAvailable, providerSendBlockReason, selectedPromptEffort, @@ -6042,13 +6145,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {activePendingApproval ? ( - + + + & { - density?: "default" | "comfortable"; + density?: "default" | "comfortable" | "spacious"; placement?: "attached" | "floating"; variant?: ComposerBannerVariant; width?: "fill" | "content"; @@ -163,6 +163,7 @@ function Root({ className={cn( "min-w-0 px-1 pt-(--composer-banner-padding-block) pb-[calc(var(--chat-composer-attachment-overlap)+var(--composer-banner-padding-block))] text-xs/4 [--composer-banner-icon-column:--spacing(7)] [--composer-banner-padding-block:--spacing(1)] sm:[--composer-banner-icon-column:--spacing(6)]", density === "comfortable" && "[--composer-banner-padding-block:--spacing(1.25)]", + density === "spacious" && "px-3 [--composer-banner-padding-block:--spacing(3)]", width === "content" ? "w-fit max-w-full flex-none" : "@container", className, )} @@ -182,7 +183,7 @@ function Row({ layout = "inline", ...props }: useRender.ComponentProps<"div"> & { - layout?: "inline" | "wrap-actions" | "wrap-actions-narrow"; + layout?: "inline" | "wrap-actions" | "wrap-actions-narrow" | "approval"; }) { const rowProps = { className: cn( @@ -193,6 +194,7 @@ function Row({ "@max-[400px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column)", layout === "wrap-actions-narrow" && "@max-[320px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column)", + layout === "approval" && "items-start gap-x-2 gap-y-3", className, ), "data-composer-banner-row": "true", @@ -212,6 +214,7 @@ function Icon({ className, ...props }: ComponentProps<"span">) { data-slot="composer-banner-icon" className={cn( "col-start-1 row-start-1 flex w-(--composer-banner-icon-column) min-w-0 flex-none items-center justify-center text-muted-foreground [&>svg]:size-3", + "group-data-[composer-banner-layout=approval]/banner-row:pt-0.5 group-data-[composer-banner-layout=approval]/banner-row:text-warning group-data-[composer-banner-layout=approval]/banner-row:[&>svg]:size-4", className, )} {...props} @@ -225,6 +228,7 @@ function Content({ className, ...props }: ComponentProps<"span">) { data-slot="composer-banner-content" className={cn( "col-start-2 row-start-1 flex min-w-0 items-center gap-1 *:data-[slot=composer-banner-separator]:mx-0", + "@max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-end-4", "group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:col-[1/3] group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:ps-2 sm:group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:ps-1.5", "group-not-has-[>[data-slot=composer-banner-icon],>[data-slot=composer-banner-actions]]/banner-row:pe-2 sm:group-not-has-[>[data-slot=composer-banner-icon],>[data-slot=composer-banner-actions]]/banner-row:pe-1.5", className, @@ -252,6 +256,7 @@ function Actions({ className, ...props }: ComponentProps<"span">) { data-slot="composer-banner-actions" className={cn( "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1", + "group-data-[composer-banner-layout=approval]/banner-row:self-center group-data-[composer-banner-layout=approval]/banner-row:gap-1.5 @max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-start-2 @max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-end-4 @max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:row-start-2", "@max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:col-start-2 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:col-end-4 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:row-start-2 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:-ms-2 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:justify-start", "@max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:col-start-2 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:col-end-4 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:row-start-2 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:-ms-2 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:justify-start", className, diff --git a/apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx b/apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx index a68ea539ebed..0db4b9f76aaf 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx @@ -5,7 +5,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; describe("ComposerPendingApprovalActions", () => { - it("states that the persistent approval lasts for this session", () => { + it("keeps the main decisions visible and secondary decisions in the menu", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain(">Cancel<"); - expect(markup).toContain("Always allow this session"); - expect(markup).not.toContain(">Always allow<"); - expect(markup).toContain("h-5"); - expect(markup).toContain("sm:text-[11px]"); - expect(markup).not.toContain("sm:h-6"); + expect(markup).toContain(">Decline<"); + expect(markup).toContain(">Approve<"); + expect(markup).not.toContain(">Cancel<"); + expect(markup).not.toContain("Always allow this session"); }); - it("shows only the approval choices advertised by an MCP server", () => { + it("keeps secondary provider labels out of the compact action row", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain("Always allow Safari"); + expect(markup).not.toContain("Always allow Safari"); expect(markup).toContain(">Approve<"); expect(markup).not.toContain("Always allow this session"); }); - it("marks an option that carries a provider warning", () => { + it("preserves provider labels for the main decisions", () => { const markup = renderToStaticMarkup( undefined} />, ); - expect(markup).toContain( - 'aria-description="Untrusted files could re-run this action without asking."', - ); - expect(markup).toContain("text-warning"); - expect(markup).toContain("Allow for this thread"); - }); - - it("limits provider-supplied approval labels so narrow rows can wrap", () => { - const label = "Allow ".repeat(40).trim(); - const markup = renderToStaticMarkup( - undefined} - />, - ); - - expect(markup).toContain('class="max-w-40 truncate"'); - expect(markup).toContain(label); + expect(markup).toContain("Allow once"); + expect(markup).toContain("Deny"); + expect(markup).not.toContain(">Approve<"); + expect(markup).not.toContain(">Decline<"); }); }); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx index 33f5afe50d75..743d846dc5e2 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx @@ -4,9 +4,11 @@ import { type ProviderApprovalOption, } from "@t3tools/contracts"; import { memo } from "react"; -import { TriangleAlertIcon } from "lucide-react"; +import { EllipsisIcon, TriangleAlertIcon } from "lucide-react"; import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { composerFloatingLayerProps } from "./composerEventScope"; interface ComposerPendingApprovalActionsProps { requestId: ApprovalRequestId; @@ -18,7 +20,6 @@ interface ComposerPendingApprovalActionsProps { ) => Promise; } -const APPROVAL_ACTION_CLASS_NAME = "font-normal"; const DEFAULT_APPROVAL_OPTIONS = [ { decision: "cancel", label: "Cancel" }, { decision: "decline", label: "Decline" }, @@ -32,23 +33,21 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro options = DEFAULT_APPROVAL_OPTIONS, onRespondToApproval, }: ComposerPendingApprovalActionsProps) { + const primaryOptions = options.filter( + (option) => option.decision === "decline" || option.decision === "accept", + ); + const moreOptions = options.filter( + (option) => option.decision !== "decline" && option.decision !== "accept", + ); + return ( <> - {options.map((option) => { + {primaryOptions.map((option) => { const button = ( ); - // A provider caution, such as a prompt injection warning on "allow - // always", rides along as a tooltip so the row stays one line. return option.warning ? ( @@ -70,6 +67,48 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro button ); })} + {moreOptions.length > 0 ? ( + + } + > + + + + {moreOptions.map((option) => { + const item = ( + void onRespondToApproval(requestId, option.decision)} + variant="ghost" + className="mb-1 last:mb-0" + > + {option.warning ? : null} + {option.label} + + ); + return option.warning ? ( + + + + {option.warning} + + + ) : ( + item + ); + })} + + + ) : null} ); }); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx index a7ef6bc7d5cb..7a32fdfe95d6 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx @@ -19,19 +19,7 @@ describe("ComposerPendingApprovalPanel", () => { />, ); - expect(markup).toContain('data-approval-detail="complete"'); - expect(markup).toContain('aria-label="Command"'); - expect(markup).toContain('role="group"'); - expect(markup).toContain('tabindex="0"'); expect(markup).toContain(detail); - expect(markup).toContain("max-h-20"); - expect(markup).toContain("overflow-auto"); - expect(markup).toContain("whitespace-pre"); - expect(markup).toContain("[scrollbar-width:thin]"); - expect(markup).toContain("[&::-webkit-scrollbar]:h-1.5"); - expect(markup).not.toContain("truncate"); - expect(markup).not.toContain("line-clamp"); - expect(markup).toContain("min-w-0"); expect(markup).not.toContain("Command approval requested"); }); @@ -65,13 +53,11 @@ describe("ComposerPendingApprovalPanel", () => { />, ); - expect(markup).toContain('aria-label="App access approval"'); - expect(markup).toContain('aria-label="App access request"'); expect(markup).toContain(">Safari<"); expect(markup).toContain("Allow ChatGPT to use Safari?"); }); - it("limits long app names so the complete approval message stays readable", () => { + it("preserves the full app name and approval message", () => { const appName = "A".repeat(200); const detail = "Allow ChatGPT to access the selected application?"; const markup = renderToStaticMarkup( @@ -87,9 +73,7 @@ describe("ComposerPendingApprovalPanel", () => { />, ); - expect(markup).toContain("max-w-32 shrink truncate"); expect(markup).toContain(appName); - expect(markup).toContain('data-approval-detail="complete"'); expect(markup).toContain(detail); }); }); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index deb5ba54c53c..8065556cebc5 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -13,6 +13,7 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova pendingCount, className, }: ComposerPendingApprovalPanelProps) { + const Detail = approval.requestKind === "mcp-elicitation" ? "span" : "code"; const fallbackLabel = approval.requestKind === "mcp-elicitation" ? "App access approval" @@ -33,27 +34,29 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova return ( - {approval.appName ? ( - - {approval.appName} - - ) : null} - + {fallbackLabel} + {approval.appName ? {approval.appName} : null} + {pendingCount > 1 ? ( + 1/{pendingCount} + ) : null} + + {approval.detail || fallbackLabel} - - {pendingCount > 1 ? ( - - 1/{pendingCount} - - ) : null} + ); }); diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 4942b21dd600..6ac54d9efdf4 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -33,7 +33,7 @@ function stashEntrySnippet(entry: PromptStashEntry): string { * Attached banner listing the stashed prompts. Opened by the stash badge or ⌘S * when the empty composer cannot restore a single entry. Navigated with arrows, * restored with Enter, dismissed with Escape. The listener runs capture-phase - * on window so it wins over the Lexical editor's handlers while the menu is open. + * on window so it wins over the composer's handlers while the menu is open. */ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { entries: ReadonlyArray; diff --git a/apps/web/src/components/chat/ComposerTasksBadge.tsx b/apps/web/src/components/chat/ComposerTasksBadge.tsx index 2eca3e23739a..19cba9048a4f 100644 --- a/apps/web/src/components/chat/ComposerTasksBadge.tsx +++ b/apps/web/src/components/chat/ComposerTasksBadge.tsx @@ -1,4 +1,4 @@ -import { ListTodoIcon } from "lucide-react"; +import { CheckIcon, CircleDotIcon, CircleIcon, ListTodoIcon } from "lucide-react"; import { memo, type ComponentProps } from "react"; import { formatDuration } from "../../session-logic"; @@ -90,7 +90,7 @@ function TaskSummary({ className={progress.completedSteps >= progress.totalSteps ? "text-success" : undefined} data-composer-task-progress="true" > - {progress.completedSteps}/{progress.totalSteps} complete + {progress.completedSteps}/{progress.totalSteps} @@ -166,10 +166,10 @@ export const ComposerTasksContent = memo(function ComposerTasksContent({ data-composer-tasks-list="true" > {keyedTaskSteps(steps).map(({ key, step }) => ( - }> + } className="items-start py-1 pe-2"> - {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"} + {step.status === "completed" ? ( + + ) : step.status === "inProgress" ? ( + + ) : ( + + )} + {taskStatusLabels[step.status]}: {step.step} - - {taskStatusLabels[step.status]} - {step.durationMs !== undefined diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 1455298e54d6..b2b46302dd25 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1950,37 +1950,303 @@ describe("deriveMessagesTimelineRows", () => { }, }); - it("keeps a thought-only turn out of the work fold", () => { + it("keeps all thoughts in one activity row as current and earlier traces stream", () => { + const entries = [1, 2, 3, 4].map((second) => { + const entry = reasoningEntry(`reasoning-${second}`, `2026-01-01T00:00:0${second}Z`, "turn-1"); + return { + ...entry, + message: { + ...entry.message, + text: `Step ${second}`, + streaming: second === 2 || second === 4, + }, + }; + }); + const input = { + timelineEntries: entries, + runningTurnId: TurnId.make("turn-1"), + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + } satisfies Parameters[0]; + const initial = deriveMessagesTimelineRowsWithState(input); + expect(initial.rows.map((row) => row.kind)).toEqual(["working", "activity-group"]); + expect(initial.rows.at(-1)).toMatchObject({ + id: "live-activity-row", + entries, + expanded: false, + active: true, + }); + expect(deriveMessagesTimelineRowsWithState(input, initial).rows).toBe(initial.rows); + const stable = computeStableMessagesTimelineRows(initial.rows, { byId: new Map(), result: [] }); + expect(computeStableMessagesTimelineRows(deriveMessagesTimelineRows(input), stable)).toBe( + stable, + ); + + for (const index of [3, 1]) { + const entry = entries[index]!; + const updatedEntry = { ...entry, message: { ...entry.message, text: "Updated trace" } }; + const updatedEntries = entries.map((entry, position) => + position === index ? updatedEntry : entry, + ); + const updatedInput = { ...input, timelineEntries: updatedEntries }; + const updated = deriveMessagesTimelineRowsWithState(updatedInput, initial); + expect(updated.rows).toEqual(deriveMessagesTimelineRows(updatedInput)); + const updatedStable = computeStableMessagesTimelineRows(updated.rows, stable); + expect(updatedStable.byId.get("live-activity-row")).not.toBe( + stable.byId.get("live-activity-row"), + ); + expect(updatedStable.byId.get("live-activity-row")).toMatchObject({ + entries: updatedEntries, + }); + expect(updatedStable.byId.get("working-indicator-row")).toBe( + stable.byId.get("working-indicator-row"), + ); + expect(initial.rows.at(-1)).toMatchObject({ entries }); + } + }); + + it("updates the same collapsed row as tools and thoughts alternate", () => { + const first = reasoningEntry("thought-first", "2026-01-01T00:00:01Z", "turn-1"); + const current = reasoningEntry("thought-current", "2026-01-01T00:00:02Z", "turn-1"); + current.message.streaming = true; + const tool = toolEntry("tool-current", "2026-01-01T00:00:03Z", "turn-1"); + const runningTool = { + ...tool, + entry: { ...tool.entry, command: "pwd", toolLifecycleStatus: "inProgress" as const }, + }; + const next = reasoningEntry("thought-next", "2026-01-01T00:00:04Z", "turn-1"); + next.message.streaming = true; + const completed = { ...current, message: { ...current.message, streaming: false } }; + for (const entries of [ + [first, current], + [first, current, runningTool], + [first, current, runningTool, next], + [first, completed], + ]) { + const input = { + timelineEntries: entries, + runningTurnId: TurnId.make("turn-1"), + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + } satisfies Parameters[0]; + const rows = deriveMessagesTimelineRows(input); + expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group"]); + expect(rows.at(-1)).toMatchObject({ + id: "live-activity-row", + entries, + active: true, + expanded: false, + }); + const expanded = deriveMessagesTimelineRows({ + ...input, + expandedWorkGroupIds: new Set(["activity-group:thought-first"]), + }); + expect(expanded.at(-1)).toMatchObject({ id: "live-activity-row", entries, expanded: true }); + } + }); + + it.each(["assistant", "user", "error", "turn"] as const)( + "keeps activity separate across a %s boundary", + (boundary) => { + const first = reasoningEntry("reasoning-first", "2026-01-01T00:00:01Z", "turn-1"); + const last = reasoningEntry( + "reasoning-last", + "2026-01-01T00:00:03Z", + boundary === "turn" ? "turn-2" : "turn-1", + ); + const answer = answerEntry("answer-between", "2026-01-01T00:00:02Z", "turn-1"); + const error = toolEntry("error-between", "2026-01-01T00:00:02Z", "turn-1"); + const middle = + boundary === "turn" + ? [] + : boundary === "error" + ? [{ ...error, entry: { ...error.entry, tone: "error" as const } }] + : [ + { + ...answer, + message: { + ...answer.message, + role: boundary === "user" ? ("user" as const) : ("assistant" as const), + }, + }, + ]; + const rows = deriveMessagesTimelineRows({ + timelineEntries: [first, ...middle, last], + expandedTurnIds: new Set([TurnId.make("turn-1"), TurnId.make("turn-2")]), + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.filter((row) => row.kind === "activity-group")).toMatchObject([ + { entries: [first], active: false }, + { entries: [last], active: false }, + ]); + if (boundary === "error") { + expect(rows).toContainEqual(expect.objectContaining({ kind: "work", id: error.id })); + } + }, + ); + + it("does not combine thoughts without a known turn", () => { + const thoughts = [1, 2].map((second) => + reasoningEntry(`unknown-${second}`, `2026-01-01T00:00:0${second}Z`, null), + ); const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"), - answerEntry("assistant-entry", "2026-01-01T00:00:02Z", "turn-1"), - ], + timelineEntries: thoughts, isWorking: false, activeTurnStartedAt: null, turnDiffSummaries: [], supportsConversationRollback: false, }); + expect(rows.map((row) => row.id)).toEqual(thoughts.map((entry) => entry.id)); + }); + it("keeps a thought-only turn out of the work fold", () => { + const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [thought, answerEntry("assistant-entry", "2026-01-01T00:00:02Z", "turn-1")], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); - expect(rows.map((row) => row.id)).toContain("reasoning-entry"); + expect(rows.find((row) => row.kind === "activity-group")).toMatchObject({ + entries: [thought], + expanded: false, + }); }); - it("folds a thinking block away with the tool work beside it", () => { + it("keeps the assistant footer before a trailing thought-only group", () => { + const answer = answerEntry("assistant-entry", "2026-01-01T00:00:01Z", "turn-1"); + const thought = reasoningEntry("reasoning-after", "2026-01-01T00:00:02Z", "turn-1"); const rows = deriveMessagesTimelineRows({ + timelineEntries: [answer, thought], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.map((row) => row.kind)).toEqual(["message", "activity-group"]); + expect(rows[0]).toMatchObject({ message: answer.message, showAssistantMeta: true }); + expect(rows[1]).toMatchObject({ entries: [thought] }); + }); + + it("keeps thoughts and tools in one activity row across a failed tool", () => { + const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"); + const tools = ["a", "b", "c"].map((id, index) => { + const entry = toolEntry(id, `2026-01-01T00:00:0${index + 2}Z`, "turn-1"); + return { + ...entry, + entry: { + ...entry.entry, + command: `echo ${id}`, + toolCallId: id, + toolLifecycleStatus: id === "b" ? ("failed" as const) : ("completed" as const), + sourceActivityKind: "tool.completed" as const, + }, + }; + }); + const input = { + timelineEntries: [thought, ...tools], + runningTurnId: TurnId.make("turn-1"), + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + } satisfies Parameters[0]; + const rows = deriveMessagesTimelineRows(input); + expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group"]); + expect(rows.at(-1)).toMatchObject({ + id: "live-activity-row", + entries: [thought, ...tools], + active: true, + }); + const settled = deriveMessagesTimelineRows({ + ...input, timelineEntries: [ - reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"), - toolEntry("tool-entry", "2026-01-01T00:00:02Z", "turn-1"), - answerEntry("assistant-entry", "2026-01-01T00:00:03Z", "turn-1"), + thought, + ...tools, + reasoningEntry("reasoning-next", "2026-01-01T00:00:05Z", "turn-1"), + { ...tools[1]!, id: "d", entry: { ...tools[1]!.entry, id: "d", toolCallId: "d" } }, + ], + isWorking: false, + activeTurnStartedAt: null, + }); + expect(settled.map((row) => row.kind)).toEqual(["activity-group"]); + }); + + it.each(["failed", "declined"] as const)( + "settles the activity row while the latest tool is %s", + (status) => { + const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"); + const tool = toolEntry("last-tool", "2026-01-01T00:00:02Z", "turn-1"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + thought, + { + ...tool, + entry: { + ...tool.entry, + command: "echo nope", + toolCallId: "last-tool", + toolLifecycleStatus: status, + sourceActivityKind: "tool.completed" as const, + }, + }, + ], + runningTurnId: TurnId.make("turn-1"), + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group", "thinking"]); + expect(rows[1]).toMatchObject({ id: "activity-group:reasoning-entry", active: false }); + expect(rows[2]).toMatchObject({ id: "live-activity-row" }); + }, + ); + + it("folds mixed activity under worked-for and restores ordered details when expanded", () => { + const entries = [ + reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"), + toolEntry("tool-entry", "2026-01-01T00:00:02Z", "turn-1"), + reasoningEntry("reasoning-next", "2026-01-01T00:00:03Z", "turn-1"), + ]; + const input = { + timelineEntries: [ + ...entries, + answerEntry("assistant-entry", "2026-01-01T00:00:04Z", "turn-1"), ], isWorking: false, activeTurnStartedAt: null, turnDiffSummaries: [], supportsConversationRollback: false, + } satisfies Parameters[0]; + const rows = deriveMessagesTimelineRows(input); + expect(rows.map((row) => row.kind)).toEqual(["turn-fold", "message"]); + const expanded = deriveMessagesTimelineRows({ + ...input, + expandedTurnIds: new Set([TurnId.make("turn-1")]), }); - - expect(rows.some((row) => row.kind === "turn-fold")).toBe(true); - expect(rows.map((row) => row.id)).not.toContain("reasoning-entry"); + expect(expanded.filter((row) => row.kind === "activity-group")).toMatchObject([ + { entries, expanded: false, active: false }, + ]); + const details = deriveMessagesTimelineRows({ + ...input, + expandedTurnIds: new Set([TurnId.make("turn-1")]), + expandedWorkGroupIds: new Set(["activity-group:reasoning-entry"]), + }); + expect(details.find((row) => row.kind === "activity-group")).toMatchObject({ + entries, + expanded: true, + }); + expect(deriveMessagesTimelineRows(input)).toEqual(rows); }); it("still folds a lone trailing tool call when a thought follows the answer", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 244185fa705c..57ed45a89d1d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,3 +1,5 @@ +import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; +export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import * as Equal from "effect/Equal"; import { shallow } from "zustand/vanilla/shallow"; import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; @@ -312,7 +314,29 @@ export type TimelineLatestTurn = Pick< const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; +type ActivityEntry = Extract; + +function isActivityEntry(entry: TimelineEntry): entry is ActivityEntry { + return entry.kind === "message" + ? entry.message.role === "reasoning" + : entry.kind === "work" && + entry.entry.agentSpawn === undefined && + entry.entry.questionAnswer === undefined && + entry.entry.sourceActivityKind !== "context-compaction" && + entry.entry.tone !== "error"; +} + export type MessagesTimelineRow = + | { + kind: "activity-group"; + id: string; + createdAt: string; + turnId: TurnId; + groupId: string; + entries: ActivityEntry[]; + expanded: boolean; + active: boolean; + } | { kind: "work"; id: string; @@ -587,7 +611,7 @@ function deriveActiveVisualResponseTurnIds(input: { return turnIds; } -function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { +export function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { return ( entry.toolLifecycleStatus === "inProgress" || (entry.toolLifecycleStatus === undefined && @@ -819,7 +843,12 @@ function attachTrailingToolGroupsToAssistant( if (candidate.kind === "message") { break; } - if (candidate.kind === "work-toggle" && candidate.turnId === turnId) { + if ( + (candidate.kind === "work-toggle" || + (candidate.kind === "activity-group" && + candidate.entries.some((entry) => entry.kind === "work"))) && + candidate.turnId === turnId + ) { hasTrailingToolGroup = true; lastTrailingWorkIndex = index; continue; @@ -996,6 +1025,7 @@ export function deriveMessagesTimelineRows(input: { break; } activeToolEntries.unshift(entry); + if (workEntryDisplayIndicatesToolFailure(entry.entry)) break; } const visibleActiveToolEntries = omitSupersededLifecycleMarkers( activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)), @@ -1074,6 +1104,7 @@ export function deriveMessagesTimelineRows(input: { ); }; + let scannedActivityThrough = -1; for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; if (!timelineEntry) { @@ -1104,6 +1135,50 @@ export function deriveMessagesTimelineRows(input: { continue; } + const activityTurnId = timelineEntryTurnId(timelineEntry); + if (index > scannedActivityThrough && activityTurnId && isActivityEntry(timelineEntry)) { + const entries = [timelineEntry]; + let cursor = index + 1; + while (cursor < input.timelineEntries.length) { + const next = input.timelineEntries[cursor]!; + if ( + !isActivityEntry(next) || + timelineEntryTurnId(next) !== activityTurnId || + collapsedEntryIds.has(next.id) || + foldsByAnchorEntryId.has(next.id) + ) + break; + entries.push(next); + cursor += 1; + } + scannedActivityThrough = cursor - 1; + if (entries.some((entry) => entry.kind === "message")) { + const active = + input.isWorking && + activityTurnId === unsettledTurnId && + cursor === input.timelineEntries.length && + !latestToolFailed && + (latestVisibleToolEntry === undefined || latestToolKeepsActivityLive); + const groupId = + timelineEntry.kind === "work" + ? workGroupId(timelineEntry.id, timelineEntry.entry) + : `activity-group:${timelineEntry.id}`; + nextRows.push({ + kind: "activity-group", + id: active ? LIVE_ACTIVITY_ROW_ID : groupId, + createdAt: timelineEntry.createdAt, + turnId: activityTurnId, + groupId, + entries, + expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, + active, + }); + hasActivityRow ||= active; + index = cursor - 1; + continue; + } + } + if (activeWorkEntryIds.has(timelineEntry.id)) { continue; } @@ -1364,29 +1439,13 @@ export function deriveMessagesTimelineRows(input: { ); } } - // A live thinking block is the real version of the placeholder below, so it - // suppresses it rather than sitting under a second "Thinking" row. - const hasStreamingReasoningRow = nextRows.some( - (row) => - row.kind === "message" && - row.message.role === "reasoning" && - row.message.streaming && - row.message.turnId !== null && - row.message.turnId === unsettledTurnId, - ); - // A running setup owns the working slot above its card and shows no // activity row of its own; every other state gets the usual tail. const hasWorkingRow = nextRows.some((row) => row.kind === "working"); if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } - if ( - input.isWorking && - !setupRunning && - !hasStreamingReasoningRow && - (!hasActivityRow || latestToolFailed) - ) { + if (input.isWorking && !setupRunning && (!hasActivityRow || latestToolFailed)) { nextRows.push({ kind: "thinking", id: LIVE_ACTIVITY_ROW_ID, @@ -1408,11 +1467,6 @@ export function deriveMessagesTimelineRows(input: { export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; -/** True once the bootstrap handed off to the agent (async setup script may still run). */ -export function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { - return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done"); -} - type MessagesTimelineRowsInput = Parameters[0]; export interface MessagesTimelineRowsProjection { @@ -1468,6 +1522,18 @@ function replaceStreamingMessageRows( } if (replacements.size === 0) return previous.rows; return previous.rows.map((row) => { + if (row.kind === "activity-group") { + if (!row.entries.some((entry) => entry.kind === "message" && replacements.has(entry.message))) + return row; + return { + ...row, + entries: row.entries.map((entry) => { + if (entry.kind !== "message") return entry; + const message = replacements.get(entry.message); + return message ? { ...entry, message } : entry; + }), + }; + } if (row.kind !== "message" && row.kind !== "assistant-meta") return row; const message = replacements.get(row.message); return message ? { ...row, message } : row; @@ -1512,6 +1578,16 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean if (a.kind !== b.kind || a.id !== b.id) return false; switch (a.kind) { + case "activity-group": { + const group = b as typeof a; + return ( + a.active === group.active && + a.expanded === group.expanded && + a.groupId === group.groupId && + a.entries.length === group.entries.length && + a.entries.every((entry, index) => entry === group.entries[index]) + ); + } case "working": case "thinking": return a.createdAt === (b as typeof a).createdAt; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5807541c4cd2..483859ba0300 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -29,6 +29,8 @@ import { resolveWorkEntryToolPresentation, resolveViewedImageAsset, workEntryViewedImagePath, + summarizeToolGroup, + omitSupersededLifecycleMarkers, } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; import type { @@ -151,6 +153,8 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { CHAT_TIMELINE_ANCHOR_OFFSET, + readTimelinePosition, + rememberTimelinePosition, timelineContentOverflowsViewport, } from "./timelineScrollAnchoring"; import { MessageCopyButton } from "./MessageCopyButton"; @@ -170,6 +174,7 @@ import { deriveUnsettledTurnId, type MessagesTimelineRowsProjection, liveWorkEntryLabel, + workEntryIsActiveTurnActivity, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -281,7 +286,7 @@ interface TimelineRowSharedState { onToggleWorkGroup: (groupId: string, anchorKey: string) => void; onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; onToggleSpawnRow: (entryId: string, expanded: boolean) => void; - onToggleReasoning: (messageId: string, expanded: boolean) => void; + onToggleReasoning: (messageId: string, expanded: boolean, anchorKey: string) => void; expandedReasoningMessageIds: ReadonlySet; workGroupViewState: WorkGroupViewState; agentPanelModel: AgentPanelModel; @@ -448,6 +453,7 @@ interface MessagesTimelineProps { onContentOverflowChange?: (overflows: boolean) => void; onToolOutputCollapsedAtEnd?: () => void; onManualNavigation: () => void; + cancelPositionRestoreRef?: React.RefObject<(() => void) | null>; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; /** Non-null when older turns exist beyond the loaded window. */ @@ -506,6 +512,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onContentOverflowChange, onToolOutputCollapsedAtEnd, onManualNavigation, + cancelPositionRestoreRef, hideEmptyPlaceholder = false, topFadeEnabled = false, loadEarlier = null, @@ -514,16 +521,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({ steerQueuedMessageShortcutLabel = null, onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, }: MessagesTimelineProps) { - const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); - const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); - // Preserve member disclosure state across virtualization. + const listIdentityKey = displayThreadKey ?? routeThreadKey; + const rememberedPosition = useMemo( + () => readTimelinePosition(listIdentityKey), + [listIdentityKey], + ); + const [expandedTurnIds, setExpandedTurnIds] = useState>( + () => rememberedPosition?.disclosures?.turns ?? new Set(), + ); + const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>( + () => rememberedPosition?.disclosures?.workGroups ?? new Set(), + ); const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState>( - new Set(), + () => rememberedPosition?.disclosures?.spawnEntries ?? new Set(), ); const [expandedReasoningMessageIds, setExpandedReasoningMessageIds] = useState< ReadonlySet - >(new Set()); - const listIdentityKey = displayThreadKey ?? routeThreadKey; + >(() => rememberedPosition?.disclosures?.reasoningMessages ?? new Set()); + const [positionedThreadKey, setPositionedThreadKey] = useState(() => + rememberedPosition?.atEnd === false ? null : listIdentityKey, + ); + const restoringThreadPosition = positionedThreadKey !== listIdentityKey; const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); const listIdentityRef = useRef(listIdentityKey); const previousLatestTurnRef = useRef(latestTurn); @@ -536,12 +554,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ let paintedExpandedReasoningMessageIds = expandedReasoningMessageIds; if (listIdentityRef.current !== listIdentityKey) { listIdentityRef.current = listIdentityKey; + setPositionedThreadKey(null); previousLatestTurnRef.current = latestTurn; setSettlingListIdentity(listIdentityKey); - paintedExpandedTurnIds = new Set(); - paintedExpandedWorkGroupIds = new Set(); - paintedExpandedSpawnEntryIds = new Set(); - paintedExpandedReasoningMessageIds = new Set(); + paintedExpandedTurnIds = rememberedPosition?.disclosures?.turns ?? new Set(); + paintedExpandedWorkGroupIds = rememberedPosition?.disclosures?.workGroups ?? new Set(); + paintedExpandedSpawnEntryIds = rememberedPosition?.disclosures?.spawnEntries ?? new Set(); + paintedExpandedReasoningMessageIds = + rememberedPosition?.disclosures?.reasoningMessages ?? new Set(); setExpandedTurnIds(paintedExpandedTurnIds); setExpandedWorkGroupIds(paintedExpandedWorkGroupIds); setExpandedSpawnEntryIds(paintedExpandedSpawnEntryIds); @@ -563,10 +583,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ current.has(turnId) ? current : new Set([...current, turnId]), ); }, []); - // Scroll/disclosure state outlives virtualized rows, but never the current thread. + // Nested tool state shares the bounded thread-position cache. const workGroupViewState = useMemo( - () => ({ scrollPositions: new Map(), expandedEntries: new Set() }), - [listIdentityKey], + () => + rememberedPosition?.disclosures?.workGroupState ?? { + scrollPositions: new Map(), + expandedEntries: new Set(), + }, + [listIdentityKey, rememberedPosition], ); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [minimapStripMap] = useState(() => new Map()); @@ -671,10 +695,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure], ); const onToggleReasoning = useCallback( - (messageId: string, expanded: boolean) => { - // The anchor must be the timeline row id, which for a message row is the - // message id, or position restoration is skipped for every row. - suspendEndScrollMaintenanceForDisclosure(messageId, !expanded); + (messageId: string, expanded: boolean, anchorKey: string) => { + suspendEndScrollMaintenanceForDisclosure(anchorKey, !expanded); setExpandedReasoningMessageIds((current) => { if (current.has(messageId) === expanded) return current; const next = new Set(current); @@ -784,6 +806,130 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ]); const rows = useStableRows(rawRows, listIdentityKey); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); + const restoreRowIndex = + restoringThreadPosition && rememberedPosition?.atEnd === false + ? rows.findIndex((row) => row.id === rememberedPosition.rowId) + : -1; + const restoringAlwaysRender = useMemo( + () => + restoringThreadPosition && restoreRowIndex >= 0 ? { indices: [restoreRowIndex] } : undefined, + [restoreRowIndex, restoringThreadPosition], + ); + useLayoutEffect(() => { + if (!restoringThreadPosition || rows.length === 0) return; + const list = listRef.current; + if (!list) return; + if (citationRequest !== null) { + setPositionedThreadKey(listIdentityKey); + return; + } + let cancelled = false; + let settleFrame: number | null = null; + const viewport: HTMLElement | null = list.getScrollableNode(); + const cancelRestoration = () => { + if (cancelled) return; + cancelled = true; + if (settleFrame !== null) cancelAnimationFrame(settleFrame); + // Supersede any pending estimated-index scroll before the browser applies the gesture. + if (viewport) void list.scrollToOffset({ offset: viewport.scrollTop, animated: false }); + setPositionedThreadKey(listIdentityKey); + }; + const cancelForNavigation = () => { + cancelRestoration(); + onManualNavigation(); + }; + const onScrollKey = (event: globalThis.KeyboardEvent) => { + if ( + ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key) && + !( + event.target instanceof Element && + event.target.closest("input, textarea, [contenteditable=true]") + ) + ) + cancelForNavigation(); + }; + viewport?.addEventListener("wheel", cancelForNavigation, { passive: true }); + viewport?.addEventListener("touchmove", cancelForNavigation, { passive: true }); + viewport?.addEventListener("pointerdown", cancelForNavigation, { passive: true }); + viewport?.ownerDocument.addEventListener("keydown", onScrollKey); + const position = rememberedPosition; + const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1; + if (position?.atEnd === false) onManualNavigation(); + if (cancelPositionRestoreRef) cancelPositionRestoreRef.current = cancelRestoration; + const scrolling = + position?.atEnd === false + ? index >= 0 + ? list.scrollToIndex({ + index, + animated: false, + viewPosition: 0, + viewOffset: -position.offsetWithinRow, + }) + : list.scrollToOffset({ offset: position.scrollOffset, animated: false }) + : list.scrollToEnd({ animated: false }); + void Promise.resolve(scrolling).then(() => { + if (cancelled) return; + if (position?.atEnd !== false || index < 0) { + setPositionedThreadKey(listIdentityKey); + return; + } + // Index scrolling starts from estimates. Keep the saved row mounted + // until its measured position and the DOM agree for two layout frames. + let stableFrames = 0; + const reconcile = () => { + if (cancelled) return; + const state = list.getState(); + const rowIndex = state.indexByKey(position.rowId); + const row = rowIndex === undefined ? undefined : state.elementAtIndex(rowIndex); + const element = list.getScrollableNode(); + if (!row || !element) return; + const offset = Math.max( + 0, + Math.min( + element.scrollTop + + row.getBoundingClientRect().top - + element.getBoundingClientRect().top + + position.offsetWithinRow, + element.scrollHeight - element.clientHeight, + ), + ); + if (Math.abs(element.scrollTop - offset) > 1) { + stableFrames = 0; + void list.scrollToOffset({ offset, animated: false }).then(() => { + if (!cancelled) settleFrame = requestAnimationFrame(reconcile); + }); + return; + } + if (++stableFrames >= 2) { + setPositionedThreadKey(listIdentityKey); + } else { + settleFrame = requestAnimationFrame(reconcile); + } + }; + settleFrame = requestAnimationFrame(reconcile); + }); + return () => { + cancelled = true; + if (cancelPositionRestoreRef?.current === cancelRestoration) { + cancelPositionRestoreRef.current = null; + } + if (settleFrame !== null) cancelAnimationFrame(settleFrame); + viewport?.removeEventListener("wheel", cancelForNavigation); + viewport?.removeEventListener("touchmove", cancelForNavigation); + viewport?.removeEventListener("pointerdown", cancelForNavigation); + viewport?.ownerDocument.removeEventListener("keydown", onScrollKey); + }; + }, [ + citationRequest, + cancelPositionRestoreRef, + listIdentityKey, + listRef, + onManualNavigation, + rememberedPosition, + restoringThreadPosition, + rows, + ]); + const [timelineViewportElement, setTimelineViewportElement] = useState( null, ); @@ -804,6 +950,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onManualNavigation, }); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); + const alwaysRender = citationAlwaysRender ?? restoringAlwaysRender; const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const [minimapCurrentIndex, setMinimapCurrentIndex] = useState(null); const handleAnchorReady = useCallback( @@ -866,7 +1013,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); + if (restoringThreadPosition || state?.data !== rows) return; const isAtEnd = resolveTimelineIsAtEnd(state); + const position = state?.data?.length ? resolveWorkGroupScrollAnchor(state) : undefined; + if (position && state && isAtEnd !== undefined) { + const index = state.indexByKey(position.rowId); + const row = index === undefined ? undefined : state.elementAtIndex(index); + const element = listRef.current?.getScrollableNode(); + if (row && element) { + rememberTimelinePosition(listIdentityKey, { + ...position, + // DOM geometry includes the header and the virtualizer's layout adjustment. + offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, + scrollOffset: element.scrollTop, + atEnd: isAtEnd, + disclosures: { + turns: paintedExpandedTurnIds, + workGroups: paintedExpandedWorkGroupIds, + spawnEntries: paintedExpandedSpawnEntryIds, + reasoningMessages: paintedExpandedReasoningMessageIds, + workGroupState: workGroupViewState, + }, + }); + } + } if (isAtEnd !== undefined && !citationPositioning) { onIsAtEndChange(isAtEnd); } @@ -907,6 +1077,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); }, [ citationPositioning, + paintedExpandedTurnIds, + paintedExpandedWorkGroupIds, + paintedExpandedSpawnEntryIds, + paintedExpandedReasoningMessageIds, + workGroupViewState, + rows, + listIdentityKey, + restoringThreadPosition, listRef, minimapItems, minimapStripMap, @@ -1099,15 +1277,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ getItemType={getItemType} renderItem={renderItem} estimatedItemSize={90} - initialScrollAtEnd={citationRequest === null} + initialScrollAtEnd={citationRequest === null && rememberedPosition?.atEnd !== false} // Legend needs a data refresh to mount new pins without a scroll event. - {...(readyCitationRequest ? { dataVersion: readyCitationRequest.key } : {})} - {...(citationAlwaysRender ? { alwaysRender: citationAlwaysRender } : {})} + dataVersion={readyCitationRequest?.key ?? listIdentityKey} + {...(alwaysRender ? { alwaysRender } : {})} onLoad={onCitationListLoad} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} maintainScrollAtEnd={ citationPositioning || + (restoringThreadPosition && rememberedPosition?.atEnd === false) || anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling @@ -1117,7 +1296,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ : TIMELINE_MAINTAIN_SCROLL_AT_END } maintainVisibleContentPosition={ - citationPositioning ? false : maintainVisibleContentPosition + citationPositioning || + (restoringThreadPosition && rememberedPosition?.atEnd === false) + ? false + : maintainVisibleContentPosition } maintainScrollAtEndThreshold={1} onScroll={handleScroll} @@ -1494,6 +1676,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time row.kind === "work" || row.kind === "work-live" || row.kind === "work-toggle" || + row.kind === "activity-group" || row.kind === "thinking" || row.kind === "worktree-setup" ? "pb-2" @@ -1519,6 +1702,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time /> ) : null} {row.kind === "work-live" ? : null} + {row.kind === "activity-group" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} {row.kind === "context-compaction" ? : null} @@ -2110,8 +2294,8 @@ function RevertUserMessageButton({ * carries `group/timeline-row`; hover or focus on an existing control reveals * the time without adding a tab stop. Hidden timestamps stay outside the row * layout. Visibility changes immediately so leaving flow cannot overlap text - * during a fade-out. Render it as the row's rightmost flex child so the - * revealed time lands at the right edge, clear of disclosure controls. + * during a fade-out. Place it before any trailing disclosure control so + * revealing the time does not move the chevron. */ function TimelineRowTimestamp({ createdAt, @@ -2392,6 +2576,102 @@ function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnap ); } +function ActivityGroupTimelineRow({ + row, +}: { + row: Extract; +}) { + const ctx = use(TimelineRowCtx); + const work = omitSupersededLifecycleMarkers( + row.entries.flatMap((entry) => + entry.kind === "work" && workEntryIsVisibleInGroup(entry.entry, row.active) + ? [entry.entry] + : [], + ), + (entry) => entry, + ); + const thoughtCount = row.entries.filter((entry) => entry.kind === "message").length; + const lastThoughtIndex = row.entries.findLastIndex((entry) => entry.kind === "message"); + const trailingWork = omitSupersededLifecycleMarkers( + row.entries + .slice(lastThoughtIndex + 1) + .flatMap((entry) => + entry.kind === "work" && workEntryIsVisibleInGroup(entry.entry, row.active) + ? [entry.entry] + : [], + ), + (entry) => entry, + ); + const liveWork = trailingWork.findLast(workEntryIsActiveTurnActivity) ?? trailingWork.at(-1); + const thinking = row.active && liveWork === undefined; + const iconWork = row.active ? liveWork : work.at(-1); + const failed = iconWork !== undefined && workEntryDisplayIndicatesToolFailure(iconWork); + const label = row.active + ? liveWork + ? liveWorkEntryLabel(liveWork, ctx.workspaceRoot, true) + : "Thinking" + : work.length > 0 + ? summarizeToolGroup(work) + : `Thought${thoughtCount > 1 ? ` (×${thoughtCount})` : ""}`; + const details: ReactNode[] = []; + if (row.expanded) { + for (let index = 0; index < row.entries.length; index += 1) { + const entry = row.entries[index]!; + if (entry.kind === "work") { + const entries = [entry.entry]; + while (row.entries[index + 1]?.kind === "work") { + const next = row.entries[++index]!; + if (next.kind === "work") entries.push(next.entry); + } + details.push( + entry)} + isExpandedToolGroup + />, + ); + } else { + const messages = [entry.message]; + while (row.entries[index + 1]?.kind === "message") { + const next = row.entries[++index]!; + if (next.kind === "message") messages.push(next.message); + } + details.push( + 0} + />, + ); + } + } + } + return ( +
+ + {row.expanded ?
{details}
: null} +
+ ); +} + function ThinkingTimelineRow() { const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); // Reserve the activity row during setup so the handoff keeps the same height. @@ -2404,6 +2684,73 @@ function ThinkingTimelineRow() { ); } +/** + * Thinking inside an expanded activity group: the trace is already one click + * deep, so the text renders under its "Thought" header without another toggle. + * A group whose row already reads "Thought" (no visible tool) skips the header. + */ +function ReasoningTraceBlock({ + messages, + live, + showHeader, +}: { + messages: ReadonlyArray; + live: boolean; + showHeader: boolean; +}) { + const ctx = use(TimelineRowCtx); + const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx); + const first = messages[0]!; + const streaming = + live && + messages.some((reasoningMessage) => reasoningMessage.streaming) && + isWorking && + first.turnId !== null && + first.turnId === unsettledTurnId; + if ( + messages.every((reasoningMessage) => reasoningMessage.text.trim().length === 0) && + !streaming + ) { + return null; + } + const label = streaming ? "Thinking" : "Thought"; + return ( +
+ {showHeader ? ( +
+ + + + + {label} + {streaming ? {label} : null} + +
+ ) : null} +
+ {messages.map((reasoningMessage) => ( + + ))} +
+
+ ); +} + /** * A provider's thinking trace. Collapsed by default: reasoning is context for * the answer, not the answer. The open/closed flag lives on the list so it @@ -2415,24 +2762,14 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({ row: Extract; }) { const ctx = use(TimelineRowCtx); - const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx); const { message } = row; - // A block left open by a crashed provider or a restarted server never gets - // its completion. Only the live turn may claim to still be thinking, so a - // settled turn cannot shimmer "Thinking" at the user forever. - const streaming = - Boolean(message.streaming) && - isWorking && - message.turnId !== null && - message.turnId === unsettledTurnId; const expanded = ctx.expandedReasoningMessageIds.has(message.id); const { onToggleReasoning } = ctx; const toggle = useCallback(() => { - onToggleReasoning(message.id, !expanded); - }, [expanded, message.id, onToggleReasoning]); - const label = streaming ? "Thinking" : "Thought"; + onToggleReasoning(message.id, !expanded, row.id); + }, [expanded, message.id, row.id, onToggleReasoning]); - if (message.text.trim().length === 0 && !streaming) { + if (message.text.trim().length === 0) { return null; } @@ -2448,12 +2785,8 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({
- - {label} - {streaming ? {label} : null} + + Thought {expanded ? ( -
+
["groupedEntries"]; isExpandedToolGroup: boolean; displayLabel?: string | undefined; }) { const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx); const onToggleStandaloneEntry = useCallback( - (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed), - [anchorKey, onToggleWorkEntry], + (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed), + [disclosureAnchorKey, onToggleWorkEntry], ); const nonEmptyEntries = useMemo( () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)), @@ -2554,6 +2889,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ @@ -2580,10 +2916,12 @@ const WorkGroupSection = memo(function WorkGroupSection({ function ExpandedWorkGroupEntries({ anchorKey, + disclosureAnchorKey, entries, workspaceRoot, }: { anchorKey: string; + disclosureAnchorKey: string; entries: TimelineWorkEntry[]; workspaceRoot: string | undefined; }) { @@ -2609,9 +2947,9 @@ function ExpandedWorkGroupEntries({ const groupView = useMemo( () => ({ state: viewState, - onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed), + onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed), }), - [anchorKey, onToggleWorkEntry, viewState], + [disclosureAnchorKey, onToggleWorkEntry, viewState], ); const updateScrollFades = useCallback(() => { const element = listRef.current?.getScrollableNode(); @@ -4556,6 +4894,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { !toolIconAcceptsTint(entryIconName, entryToolIcon) ? ( ) : null} + -
{expanded && viewedImage && threadRef ? ( diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index caa595dffd86..5917b4e2ca24 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -1,6 +1,6 @@ import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; import { memo } from "react"; -import { StarIcon } from "lucide-react"; +import { CheckIcon, StarIcon } from "lucide-react"; import { getDisplayModelName, getTriggerDisplayModelLabel, @@ -31,6 +31,7 @@ export const ModelListRow = memo(function ModelListRow(props: { providerAccentColor?: string | undefined; isFavorite: boolean; isSelected: boolean; + showSelection?: boolean; showProvider: boolean; preferShortName?: boolean; useTriggerLabel?: boolean; @@ -94,6 +95,9 @@ export const ModelListRow = memo(function ModelListRow(props: {
+ {props.showSelection && props.isSelected ? ( +
- {relativePath && attachment ? ( + {isDirectory ? null : relativePath && attachment ? ( diff --git a/apps/web/src/components/files/projectFilesQueryState.test.tsx b/apps/web/src/components/files/projectFilesQueryState.test.tsx index 8edf982a030a..8ac6f4e1cd26 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.tsx +++ b/apps/web/src/components/files/projectFilesQueryState.test.tsx @@ -1,6 +1,7 @@ import { EnvironmentId, type ProjectListEntriesResult, + ProjectReadFileError, type ProjectReadFileResult, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -250,4 +251,62 @@ describe("project query refresh", () => { atomHooks.registry = null; } }); + + it("reports a directory named like an image as not a file", async () => { + const readAtom = Atom.make( + Effect.fail( + new ProjectReadFileError({ + cwd: "/repo", + relativePath: "assets.png", + failure: "path_not_file", + }), + ), + ); + const registry = AtomRegistry.make(); + const unmount = registry.mount(readAtom); + projectMocks.readFile.mockReturnValue(readAtom); + projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); + atomHooks.registry = registry; + + try { + await flushEffects(); + reactHooks.beginRender(); + const query = useProjectFileQuery(environmentId, "/repo", "assets.png"); + expect(query.isNotFile).toBe(true); + expect(query.data).toBeNull(); + } finally { + unmount(); + registry.dispose(); + atomHooks.registry = null; + } + }); + + it("reports a directory read as not a file", async () => { + const readAtom = Atom.make( + Effect.fail( + new ProjectReadFileError({ + cwd: "/repo", + relativePath: ".agents/skills", + failure: "path_not_file", + }), + ), + ); + const registry = AtomRegistry.make(); + const unmount = registry.mount(readAtom); + projectMocks.readFile.mockReturnValue(readAtom); + projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); + atomHooks.registry = registry; + + try { + await flushEffects(); + reactHooks.beginRender(); + const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills"); + expect(query.isNotFile).toBe(true); + expect(query.data).toBeNull(); + } finally { + unmount(); + registry.dispose(); + atomHooks.registry = null; + } + }); }); diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 4129ac038c35..b9a880301831 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -1,15 +1,13 @@ import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; -import type { - EnvironmentId, - ProjectListEntriesResult, - ProjectReadFileResult, -} from "@t3tools/contracts"; import { - isWorkspaceImagePreviewPath, - isWorkspaceVideoPreviewPath, -} from "@t3tools/shared/filePreview"; + type EnvironmentId, + type ProjectListEntriesResult, + ProjectReadFileError, + type ProjectReadFileResult, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback } from "react"; @@ -33,6 +31,11 @@ interface ProjectQueryState { readonly refresh: () => void; } +interface ProjectFileQueryState extends ProjectQueryState { + /** The path exists but is not a regular file, typically a directory. */ + readonly isNotFile: boolean; +} + function getProjectEntriesQueryAtom( environmentId: EnvironmentId, cwd: string, @@ -126,12 +129,17 @@ export function clearProjectFileQueryData( appAtomRegistry.set(optimisticFileAtom(environmentId, cwd, relativePath), null); } -function errorMessage(result: AsyncResult.AsyncResult): string | null { - if (result._tag !== "Failure") return null; - const cause = Cause.squash(result.cause); +function failureCause(result: AsyncResult.AsyncResult): unknown { + return result._tag === "Failure" ? Cause.squash(result.cause) : null; +} + +function errorMessage(cause: unknown): string | null { + if (cause === null) return null; return cause instanceof Error ? cause.message : "Workspace query failed."; } +const isProjectReadFileError = Schema.is(ProjectReadFileError); + export function useProjectEntriesQuery( environmentId: EnvironmentId, cwd: string, @@ -143,7 +151,7 @@ export function useProjectEntriesQuery( const refresh = useCallback(() => refreshAtom(), [refreshAtom]); return { data: Option.getOrNull(AsyncResult.value(result)), - error: errorMessage(result), + error: errorMessage(failureCause(result)), isPending: result.waiting, refresh, }; @@ -189,14 +197,12 @@ export function useProjectFileQuery( cwd: string, relativePath: string | null, enabled = true, -): ProjectQueryState { - const isMedia = - relativePath !== null && - (isWorkspaceImagePreviewPath(relativePath) || isWorkspaceVideoPreviewPath(relativePath)); - const atom = - enabled && !isMedia - ? getProjectFileQueryAtom(environmentId, cwd, relativePath) - : EMPTY_PROJECT_FILE_QUERY_ATOM; +): ProjectFileQueryState { + // The caller decides what to read. A media path is not skipped here: a folder + // named `assets.png` is only knowable as a folder from the read failure. + const atom = enabled + ? getProjectFileQueryAtom(environmentId, cwd, relativePath) + : EMPTY_PROJECT_FILE_QUERY_ATOM; const result = useAtomValue(atom); const refreshAtom = useAtomRefresh(atom); const refresh = useCallback(() => refreshAtom(), [refreshAtom]); @@ -205,10 +211,12 @@ export function useProjectFileQuery( optimisticFileAtom(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH), ); const optimisticFile = relativePath === null ? null : optimisticResult; + const cause = failureCause(result); return { data: optimisticFile?.data ?? data, - error: errorMessage(result), + error: errorMessage(cause), + isNotFile: isProjectReadFileError(cause) && cause.failure === "path_not_file", isPending: result.waiting, refresh, }; diff --git a/apps/web/src/components/pullRequest/PullRequestCommentBody.tsx b/apps/web/src/components/pullRequest/PullRequestCommentBody.tsx new file mode 100644 index 000000000000..d730c0f859a1 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestCommentBody.tsx @@ -0,0 +1,61 @@ +import { useEffect, useId, useRef, useState, type ComponentProps } from "react"; + +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { PullRequestMarkdown } from "./PullRequestMarkdown"; + +/** Keep the complete markdown intact while limiting long reports to a readable preview. */ +export function PullRequestCommentBody({ + className, + ...props +}: ComponentProps) { + const [expanded, setExpanded] = useState(false); + const [overflowing, setOverflowing] = useState(false); + const content = useRef(null); + const id = useId(); + + useEffect(() => { + const element = content.current; + if (!element) return; + const measure = () => setOverflowing(element.getBoundingClientRect().height > 240); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + return ( +
+
setExpanded(true)} + > +
+ +
+ {overflowing && !expanded ? ( +
+ ) : null} +
+ {overflowing ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index cfc876dc39c1..b5b5fa2e4a3c 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -145,6 +145,7 @@ import { PULL_REQUEST_MERGE_METHOD_LABELS, readableFailure, readPullRequestDetailSnapshot, + resolvePullRequestReferenceHost, resolveDisplayedPullRequestDetail, resolvePullRequestPrimaryControl, allowsSinglePullRequestMerge, @@ -523,18 +524,23 @@ export function PullRequestDetailPanel({ onBack?: (() => void) | undefined; }) { const environmentConfigs = useServerConfigs(); + const projects = useProjects(); + const repositoryIdentity = projects.find( + (project) => + project.id === requestedReference.projectId && project.environmentId === environmentId, + )?.repositoryIdentity; const supportsThreadPullRequests = environmentConfigs.get(environmentId)?.environment.capabilities.threadPullRequests === true; const reference = useMemo( () => supportsThreadPullRequests - ? requestedReference + ? resolvePullRequestReferenceHost(requestedReference, repositoryIdentity) : { projectId: requestedReference.projectId, repository: requestedReference.repository, number: requestedReference.number, }, - [requestedReference, supportsThreadPullRequests], + [requestedReference, repositoryIdentity, supportsThreadPullRequests], ); const pullRequestKey = `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`; const matchingListEntry = @@ -881,7 +887,6 @@ export function PullRequestDetailPanel({ const newThread = useNewThreadHandler(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const projects = useProjects(); const unavailableGitHubUrl = useMemo(() => { const identity = projects.find( (project) => project.id === reference.projectId && project.environmentId === environmentId, diff --git a/apps/web/src/components/pullRequest/PullRequestReactions.tsx b/apps/web/src/components/pullRequest/PullRequestReactions.tsx index 2ef90b09a55c..82e80b86c2f7 100644 --- a/apps/web/src/components/pullRequest/PullRequestReactions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReactions.tsx @@ -34,15 +34,7 @@ function reactionsSignature(reactions: ReadonlyArray): stri .join(" "); } -/** - * The reaction pills under a remark, and the picker that adds one. The same bar serves the - * description, a conversation comment and a review thread's comments: what differs between them - * is only which subject the host is told about. - * - * The add button is revealed by hovering the remark it belongs to, the way GitHub's is, so the - * parent must carry `group`. It stays put once there is something to press it beside, while the - * picker is open, and whenever it is focused — a control only a mouse can find is no control. - */ +/** Reaction counts and an always-visible picker, routed to the supplied host subject. */ export function PullRequestReactionBar({ reactions, canReact, @@ -98,7 +90,7 @@ export function PullRequestReactionBar({ if (shown.length === 0 && !canReact) return null; return ( -
+
{shown.map((reaction) => ( } diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index 90a1926d1fad..f1e8955c3d54 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -264,9 +264,18 @@ export function ReviewThreadCard({
{comments.map((comment) => (
-
+
{formatRelativeTimeLabel(comment.createdAt)} +
{editingId === comment.id ? ( )} -
))}
diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx index 6bf532e5aefd..57033b67154c 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx @@ -1,5 +1,5 @@ import { EnvironmentId, ProjectId, type PullRequestDetailView } from "@t3tools/contracts"; -import { act } from "react"; +import { act, type ReactNode } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; @@ -9,6 +9,11 @@ vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => vi.fn() })); vi.mock("./PullRequestMarkdown", () => ({ PullRequestMarkdown: ({ text }: { text: string }) =>

{text}

, })); +vi.mock("../ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => children, + TooltipTrigger: ({ children }: { children: ReactNode }) => children, + TooltipPopup: () => null, +})); import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; @@ -139,3 +144,71 @@ it("keeps an unsaved description when collapsed and reopened", () => { click("Description"); expect(renderer.root.findByType("textarea").props.value).toBe("Unsaved description"); }); + +it("opens bot reports in pages without hiding human comments", () => { + const value: PullRequestDetailView = { + ...detail, + commentCount: 13, + comments: Array.from({ length: 13 }, (_, index) => ({ + id: `comment-${index}`, + kind: "issue-comment", + author: { + login: index === 12 ? "human" : "review-app", + name: null, + avatarUrl: null, + isBot: index !== 12, + }, + body: index === 12 ? "Human comment" : `Bot report ${index}`, + createdAt: `2026-09-01T00:00:${String(index).padStart(2, "0")}Z`, + url: null, + path: null, + reviewState: null, + })), + }; + act(() => { + renderer = create(render(value)); + }); + expect(renderer.root.findAllByType("p").map((p) => p.children.join(""))).toContain( + "Human comment", + ); + expect( + renderer.root.findAllByType("p").some((p) => p.children.join("").startsWith("Bot report")), + ).toBe(false); + const group = renderer.root + .findAllByType("button") + .find((button) => button.props["aria-label"] === "12 bot comments")!; + act(() => group.props.onClick({ nativeEvent: {}, preventDefault() {}, stopPropagation() {} })); + expect( + renderer.root.findAllByType("p").filter((p) => p.children.join("").startsWith("Bot report")), + ).toHaveLength(10); + expect(renderer.root.findAllByType("p").map((p) => p.children.join(""))).not.toContain( + "Bot report 0", + ); + act(() => + renderer.root + .findAllByType("button") + .find((button) => button.children.includes(" older bot comment"))! + .props.onClick(), + ); + expect( + renderer.root.findAllByType("p").filter((p) => p.children.join("").startsWith("Bot report")), + ).toHaveLength(12); + act(() => group.props.onClick({ nativeEvent: {}, preventDefault() {}, stopPropagation() {} })); + act(() => group.props.onClick({ nativeEvent: {}, preventDefault() {}, stopPropagation() {} })); + expect(renderer.root.findAllByType("p").map((p) => p.children.join(""))).toContain( + "Bot report 0", + ); + act(() => + renderer.root + .findAllByType("button") + .find((button) => button.children.includes(" recent bot comments"))! + .props.onClick(), + ); + expect( + renderer.root.findAllByType("p").filter((p) => p.children.join("").startsWith("Bot report")), + ).toHaveLength(10); + act(() => renderer.update(render({ ...value, url: `${value.url}0`, number: 10 }))); + expect( + renderer.root.findAllByType("p").some((p) => p.children.join("").startsWith("Bot report")), + ).toBe(false); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 7c7c72d8d163..247f1a18f411 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -1,9 +1,9 @@ import type { EnvironmentId, - PullRequestActor, PullRequestComment, PullRequestDetailView, PullRequestRef, + PullRequestReviewThread, ScopedThreadRef, } from "@t3tools/contracts"; import { @@ -28,7 +28,6 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collaps import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { - PullRequestActorAvatar, PullRequestActorLabel, PullRequestCheckStatusIcon, PullRequestReviewOutcomeBadge, @@ -53,6 +52,7 @@ import { canEditPullRequestComment, } from "./pullRequestEditing.logic"; import { PullRequestMarkdown } from "./PullRequestMarkdown"; +import { PullRequestCommentBody } from "./PullRequestCommentBody"; import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; import { PullRequestReactionBar } from "./PullRequestReactions"; import { PullRequestConversationGhost } from "./PullRequestGhosts"; @@ -64,18 +64,69 @@ function reviewerKey(login: string): string { return login.toLowerCase(); } -/** The avatar carries the attribution alone; who it is arrives on hover, like the reviewer row. */ -function CommentAuthor({ actor }: { actor: PullRequestActor | null }) { - const login = actor?.login ?? "ghost"; +function CommentIdentity({ + comment, + detail, +}: { + comment: PullRequestComment; + detail: PullRequestDetailView; +}) { + const actor = comment.author; + const profileUrl = + detail.provider === "github" && actor && !actor.login.endsWith("[bot]") + ? new URL(`/${encodeURIComponent(actor.login)}`, detail.url).toString() + : null; + return ( +
+ + + + ) : ( + + ) + } + > + + + + {new Date(comment.createdAt).toLocaleString()} + {comment.url ? " · Open comment on host" : ""} + + +
+ ); +} + +function CommentLocation({ + comment, + thread, +}: { + comment: PullRequestComment; + thread: PullRequestReviewThread | undefined; +}) { + const path = thread?.path ?? comment.path; + if (!path) return null; + const label = `${path}${thread?.line ? `:${thread.line}` : ""}`; return ( - - }> - - - - {actor?.name && actor.name !== login ? `${actor.name} (@${login})` : login} - - +
+ + }>{label} + {label} + + {thread?.isOutdated ? Outdated : null} +
); } @@ -127,7 +178,8 @@ function CommentBody({ } return (
- (null); return (
- - - - {formatRelativeTimeLabel(comment.createdAt)} - {label} - - - +
+
+ + + {label} + + + {reactionBar} +
+ + {!open && body ? ( + statusTriggerRef.current?.focus({ preventScroll: true })} + > + {body + .replace(//gu, "") + .replace(/^\s*>?\s*\[!\w+\]\s*$/gmu, "") + .replace(/!?(\[([^\]]+)\])\([^)]*\)/gu, "$2") + .replace(/^[\s>#*-]+/gmu, "") + .replace(/[*`]/gu, "") + .replace(/\s+/g, " ") + .trim()} + + ) : null} +
{open ? (
- {comment.path ? ( - - {comment.path}

- } - /> - {comment.path} -
- ) : null} {/* A dismissal carries no more words than an approval does, and an empty markdown block reads as a card somebody forgot to fill in. */} {body === null && !editing.canEdit(comment) ? null : ( )} - {reactionBar}
) : null}
@@ -302,11 +361,107 @@ function Section({ ); } +function CommentGroup({ + label, + comments, + detail, + children, + onOpenChange, +}: { + label: string; + comments: readonly PullRequestComment[]; + detail: PullRequestDetailView; + children: ReactNode; + onOpenChange?: (open: boolean) => void; +}) { + const authors = [ + ...new Map( + comments.map((comment) => [reviewerKey(comment.author?.login ?? "ghost"), comment.author]), + ).values(), + ]; + const fileCount = new Set(comments.flatMap((comment) => (comment.path ? [comment.path] : []))) + .size; + const latest = comments.reduce( + (date, comment) => (date === null || comment.createdAt > date ? comment.createdAt : date), + null, + ); + return ( + +
+
+ {authors.slice(0, 3).map((actor) => ( + + ))} + {authors.length > 3 ? ( + + +{authors.length - 3} + + ) : null} +
+ + + {label} + + + {authors.length} {authors.length === 1 ? "author" : "authors"} + + {fileCount > 0 ? ( + + · {fileCount} {fileCount === 1 ? "file" : "files"} + + ) : null} + {latest ? ( + + · Latest{" "} + + }> + {formatRelativeTimeLabel(latest)} + + {new Date(latest).toLocaleString()} + + + ) : null} + + + + +
+ +
{children}
+
+
+ ); +} + /** * What a first render of the conversation carries. A pull request with two hundred comments is * two hundred markdown documents, and the ones worth arriving for are the recent ones. */ -const COMMENT_PAGE = 30; +const COMMENT_PAGE = 10; export function PullRequestSummaryTab({ environmentId, @@ -337,11 +492,35 @@ export function PullRequestSummaryTab({ // Keyed by the pull request, so opening another one starts at the end of its conversation // rather than wherever the last one had been read back to. const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE }); + const [openedBotGroup, setOpenedBotGroup] = useState(null); + const [shownBots, setShownBots] = useState({ url: detail.url, count: COMMENT_PAGE }); + const shownBotComments = shownBots.url === detail.url ? shownBots.count : COMMENT_PAGE; const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE; + // A comment that already lives on a review thread is that thread: the thread carries the line + // and side the bare comment has lost, and a resolved one is finished work nobody should be + // invited to fix again — the same call the whole-review hand-off makes. + const threadByCommentId = new Map( + detail.reviewThreads.flatMap((thread) => + thread.comments.map((comment) => [comment.id, thread] as const), + ), + ); + + const activeComments: PullRequestComment[] = []; + const finishedComments: PullRequestComment[] = []; + const botComments: PullRequestComment[] = []; + for (const comment of detail.comments) { + const finished = + threadByCommentId.get(comment.id)?.isResolved || + pullRequestReviewOutcome(comment.reviewState) === "dismissed"; + const bot = comment.author?.isBot === true || comment.author?.login.endsWith("[bot]"); + (finished ? finishedComments : bot ? botComments : activeComments).push(comment); + } // Windowed by recency regardless of display order: expanding always reaches further back in // time, whether the newest comment currently reads first or last. - const recentComments = detail.comments.slice(Math.max(0, detail.comments.length - shownComments)); - const hiddenCommentCount = detail.comments.length - recentComments.length; + const recentComments = activeComments.slice(Math.max(0, activeComments.length - shownComments)); + const hiddenCommentCount = activeComments.length - recentComments.length; + const recentBotComments = botComments.slice(Math.max(0, botComments.length - shownBotComments)); + const hiddenBotCommentCount = botComments.length - recentBotComments.length; const [commentOrder, setCommentOrder] = useState<"newest" | "oldest">("newest"); const visibleComments = orderPullRequestComments(recentComments, commentOrder); const showOldestCommentsButton = @@ -352,12 +531,12 @@ export function PullRequestSummaryTab({ className="w-full" onClick={() => setShown({ url: detail.url, count: shownComments + COMMENT_PAGE })} > - Show {Math.min(hiddenCommentCount, COMMENT_PAGE)} oldest{" "} - {hiddenCommentCount === 1 ? "comment" : "comments"} + Show {Math.min(hiddenCommentCount, COMMENT_PAGE)} older comment + {hiddenCommentCount === 1 ? "" : "s"} ({hiddenCommentCount} hidden) ) : null; // Read from the whole conversation, not the window shown below it: a verdict older than the - // last thirty comments still stands. + // visible comments still stands. const reviewOutcomes = latestPullRequestReviewOutcomes(detail.comments, detail.commits); // Hosts do not promise one casing for a login across two fields of the same response, and // none of them lets `Octocat` and `octocat` be two people — so matching on the literal string @@ -393,15 +572,6 @@ export function PullRequestSummaryTab({ })), ]; - // A comment that already lives on a review thread is that thread: the thread carries the line - // and side the bare comment has lost, and a resolved one is finished work nobody should be - // invited to fix again — the same call the whole-review hand-off makes. - const threadByCommentId = new Map( - detail.reviewThreads.flatMap((thread) => - thread.comments.map((comment) => [comment.id, thread] as const), - ), - ); - const openLink = useOpenLink(threadRef); const openCheck = (url: string) => { void openLink(url).catch((error: unknown) => { @@ -470,6 +640,78 @@ export function PullRequestSummaryTab({ }, }; + const renderComment = (comment: PullRequestComment) => { + const thread = threadByCommentId.get(comment.id); + const body = visibleBody(comment.body); + const outcome = pullRequestReviewOutcome(comment.reviewState); + // An approval is a verdict, not a finding: there is nothing in it to fix. + const finding: PullRequestFinding | null = + (comment.kind !== "review" && comment.kind !== "review-comment") || outcome === "approved" + ? null + : thread === undefined + ? // Nor is a remark with nothing in it: offering to hand an empty review + // to a thread promises work it does not describe. + body === null + ? null + : { kind: "comment", comment } + : { kind: "thread", thread }; + const reactionBar = ( + + ); + return ( +
+
+
+ + {outcome ? ( + + ) : comment.reviewState ? ( + {reviewStateLabel(comment.reviewState)} + ) : null} +
+ {/* Review remarks only. A plain conversation comment is talk, not a finding, + and offering to fix one would promise more than it says. */} + {onFixFinding && finding ? ( + + ) : null} + {reactionBar} +
+
+ +
+ {/* A verdict usually carries no words, and an empty markdown block reads as + a card somebody forgot to fill in — the badge above already said it. + Kept where this reader may rewrite the remark: the pencil lives in here, + and hiding the block would take away the only way back to it. */} + {body === null && !commentEditing.canEdit(comment) ? null : ( + + )} +
+ ); + }; + return (
@@ -694,7 +936,7 @@ export function PullRequestSummaryTab({
{commentOrder === "oldest" ? showOldestCommentsButton : null} - {visibleComments.map((comment) => { - const thread = threadByCommentId.get(comment.id); - const body = visibleBody(comment.body); - const outcome = pullRequestReviewOutcome(comment.reviewState); - if (thread?.isResolved || outcome === "dismissed") { - return ( - - } - /> - ); - } - // An approval is a verdict, not a finding: there is nothing in it to fix. - const finding: PullRequestFinding | null = - (comment.kind !== "review" && comment.kind !== "review-comment") || - outcome === "approved" - ? null - : thread === undefined - ? // Nor is a remark with nothing in it: offering to hand an empty review - // to a thread promises work it does not describe. - body === null - ? null - : { kind: "comment", comment } - : { kind: "thread", thread }; - // One bar, two homes. Under a card with words in it, it is the row beneath - // them. A bodiless verdict has nothing above it, so a row reserved for an add - // button nobody can see until they hover is a hole — there it rides the header - // line instead, which keeps the affordance every sibling card offers. - const reactionBar = ( - - ); - return ( -
-
- - - {formatRelativeTimeLabel(comment.createdAt)} - {outcome ? ( - - ) : comment.reviewState ? ( - {reviewStateLabel(comment.reviewState)} - ) : null} - {body === null ? reactionBar : null} - - {/* Review remarks only. A plain conversation comment is talk, not a finding, - and offering to fix one would promise more than it says. */} - {onFixFinding && finding ? ( - - ) : null} -
- {comment.path ? ( - - - {comment.path} -

+ {visibleComments.map(renderComment)} + {commentOrder === "newest" ? showOldestCommentsButton : null} + {shownComments > COMMENT_PAGE ? ( + + ) : null} + {botComments.length > 0 ? ( + { + if (open) setOpenedBotGroup(detail.url); + }} + > +
+ {openedBotGroup === detail.url + ? orderPullRequestComments(recentBotComments, commentOrder).map( + renderComment, + ) + : null} + {hiddenBotCommentCount > 0 ? ( + + ) : null} + {shownBotComments > COMMENT_PAGE ? ( + + ) : null} +
+
+ ) : null} + {finishedComments.length > 0 ? ( + +
+ {orderPullRequestComments(finishedComments, commentOrder).map((comment) => { + const thread = threadByCommentId.get(comment.id); + return ( + } /> - {comment.path} - - ) : null} - {/* A verdict usually carries no words, and an empty markdown block reads as - a card somebody forgot to fill in — the badge above already said it. - Kept where this reader may rewrite the remark: the pencil lives in here, - and hiding the block would take away the only way back to it. */} - {body === null && !commentEditing.canEdit(comment) ? null : ( - - )} - {body === null ? null : reactionBar} -
- ); - })} - {commentOrder === "newest" ? showOldestCommentsButton : null} + ); + })} +
+ + ) : null}
)} diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index 21037d8bf4c0..b114250549d2 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -217,7 +217,7 @@ function ConversationCard({ return (
-
+
@@ -245,6 +245,17 @@ function ConversationCard({ ) : null} + {reactions.canReact || event.reactions.length > 0 ? ( + + ) : null}
@@ -272,18 +283,6 @@ function ConversationCard({ />
) : null} - {reactions.canReact || event.reactions.length > 0 ? ( -
- -
- ) : null}
); } @@ -469,14 +468,14 @@ function ReviewVerdictEvent({ }) { return (
- {/* Pinned rather than centred: this row grows with a body and a reaction bar, and a - centred avatar drifts down beside them instead of sitting by the name. */} + {/* Pinned rather than centred: this row grows with a body, and a + centred avatar drifts down beside it instead of sitting by the name. */} } /> -
+
@@ -504,9 +503,6 @@ function ReviewVerdictEvent({ {pullRequestReviewOutcomeStaleLabel(outcome)}
- {/* The reaction bar rides this line rather than taking one of its own. Its add button - is invisible until hovered but still occupies `h-6`, and under a verdict — usually a - single line with no body — a row of that reserved on its own reads as a hole. */}
{formatRelativeTimeLabel(event.at)} @@ -517,31 +513,32 @@ function ReviewVerdictEvent({ ) : null} - {reactions.canReact || event.reactions.length > 0 ? ( - - ) : null}
- {/* An approval usually carries no words. When it does they are the review, so they stay - visible rather than being folded away with the ordinary conversation. */} - {event.body ? ( - - ) : null}
+ {reactions.canReact || event.reactions.length > 0 ? ( + + ) : null}
+ {/* An approval usually carries no words. When it does they are the review, so they stay + visible rather than being folded away with the ordinary conversation. */} + {event.body ? ( + + ) : null}
); } diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index ae5a5067bf12..96133395f048 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -1,6 +1,7 @@ import { resolvePlanFollowUpSubmission } from "../../proposedPlan"; import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; import { + ProjectId, PullRequestAction, type PullRequestCheck, type PullRequestComment, @@ -40,6 +41,7 @@ import { readableFailure, readPullRequestDetailSnapshot, resolveDisplayedPullRequestDetail, + resolvePullRequestReferenceHost, resolvePullRequestPrimaryControl, allowsSinglePullRequestMerge, shouldRefreshPullRequestActivity, @@ -1440,7 +1442,7 @@ describe("which actions need the host read again after they run", () => { }); describe("cached pull request detail", () => { - const reference = { projectId: "project-1", repository: "acme/web", number: 7 }; + const reference = { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 7 }; const detail = (overrides: Partial = {}): PullRequestDetail => ({ provider: "github", @@ -1511,6 +1513,106 @@ describe("cached pull request detail", () => { expect(snapshot?.deletions).toBe(3); }); + it("reuses a host-qualified snapshot when reopening a thread link without a host", () => { + const storage = makeStorage(); + writePullRequestDetailSnapshot( + storage, + "env-1", + { ...reference, host: "github.com" }, + detail(), + ); + const resolved = resolvePullRequestReferenceHost(reference, { + canonicalKey: "github.com/acme/web", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/acme/web.git", + }, + provider: "github", + }); + expect(readPullRequestDetailSnapshot(storage, "env-1", resolved)?.title).toBe( + "Cache the title", + ); + const explicit = { ...reference, host: "github.example.com" }; + expect( + resolvePullRequestReferenceHost(explicit, { + canonicalKey: "github.com/acme/web", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/acme/web.git", + }, + }), + ).toBe(explicit); + }); + + it("leaves server-resolved Azure SSH references unchanged", () => { + expect( + resolvePullRequestReferenceHost(reference, { + canonicalKey: "ssh.dev.azure.com/v3/org/project/web", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@ssh.dev.azure.com:v3/org/project/web", + }, + provider: "azure-devops", + }), + ).toBe(reference); + expect(resolvePullRequestReferenceHost(reference, undefined)).toBe(reference); + }); + + it("hydrates legacy hostless snapshots only for the matching host", () => { + const storage = makeStorage(); + writePullRequestDetailSnapshot(storage, "env-1", reference, detail()); + expect( + readPullRequestDetailSnapshot(storage, "env-1", { ...reference, host: "github.com" })?.title, + ).toBe("Cache the title"); + expect( + readPullRequestDetailSnapshot(storage, "env-1", { + ...reference, + host: "github.example.com", + }), + ).toBeNull(); + }); + + it("keeps Forgejo ports isolated when recovering legacy snapshots", () => { + const storage = makeStorage(); + const cached = detail({ + provider: "forgejo", + url: "https://forge.example:8443/acme/web/pulls/7", + }); + writePullRequestDetailSnapshot(storage, "env-1", reference, cached); + const resolved = { ...reference, host: "forge.example:8443" }; + expect(readPullRequestDetailSnapshot(storage, "env-1", resolved)?.title).toBe(cached.title); + expect( + readPullRequestDetailSnapshot(storage, "env-1", { + ...reference, + host: "forge.example:9443", + }), + ).toBeNull(); + expect( + readPullRequestDetailSnapshot(storage, "env-1", { + ...reference, + host: "forge.example", + }), + ).toBeNull(); + }); + + it.each(["github", "gitlab"] as const)( + "retains portless %s snapshot identities for custom web ports", + (provider) => { + const storage = makeStorage(); + const host = `${provider}.example.com`; + const hosted = { ...reference, host }; + const cached = detail({ + provider, + url: `https://${host}:8443/acme/web/${provider === "github" ? "pull" : "-/merge_requests"}/7`, + }); + writePullRequestDetailSnapshot(storage, "env-1", hosted, cached); + expect(readPullRequestDetailSnapshot(storage, "env-1", hosted)?.title).toBe(cached.title); + }, + ); + it("keeps a cached tab painted while the live read replaces the counts", () => { const cached = detail(); const live = detail({ additions: 40, deletions: 9, title: "Cache the title" }); @@ -1534,11 +1636,11 @@ describe("cached pull request detail", () => { it("isolates stored and displayed details between hosts with the same repository and number", () => { const storage = makeStorage(); const publicRef = { ...reference, host: "github.com" }; - const enterpriseRef = { ...reference, host: "github.example.com" }; + const enterpriseRef = { ...reference, host: "ghe.example.com" }; const publicDetail = detail(); const enterpriseDetail = detail({ title: "Enterprise change", - url: "https://github.example.com/acme/web/pull/7", + url: "https://ghe.example.com/acme/web/pull/7", }); writePullRequestDetailSnapshot(storage, "env-1", publicRef, publicDetail); expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull(); @@ -1572,6 +1674,9 @@ describe("cached pull request detail", () => { storage.setItem("t3.pullRequests.detail:env-1:project-1:acme/web#7", "{not json"); expect(readPullRequestDetailSnapshot(storage, "env-1", reference)).toBeNull(); expect(readPullRequestDetailSnapshot(undefined, "env-1", reference)).toBeNull(); + const hosted = { ...reference, host: "github.com" }; + writePullRequestDetailSnapshot(storage, "env-1", hosted, detail({ url: "invalid url" })); + expect(readPullRequestDetailSnapshot(storage, "env-1", hosted)).toBeNull(); }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index be71c95ddc40..56e22e643e1e 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -1,8 +1,8 @@ import * as Schema from "effect/Schema"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { PullRequestDetail, + pullRequestHostOf, type PullRequestAction, type PullRequestActor, type PullRequestBaseComparison, @@ -15,6 +15,8 @@ import { type PullRequestMergeability, type PullRequestMergeMethod, type PullRequestReaction, + type PullRequestRef, + type RepositoryIdentity, type PullRequestReviewThread, type PullRequestState, type PullRequestUpdateMethod, @@ -1092,6 +1094,15 @@ export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): bo type SnapshotStorage = Pick; +export function resolvePullRequestReferenceHost( + reference: PullRequestRef, + identity: RepositoryIdentity | null | undefined, +): PullRequestRef { + // Other providers may resolve an SSH remote to a different web authority on the server. + if (reference.host !== undefined || identity?.provider !== "github") return reference; + return { ...reference, host: pullRequestHostOf(identity, "github") }; +} + export interface PullRequestDetailSnapshotRef { readonly host?: string | undefined; readonly projectId: string; @@ -1121,7 +1132,13 @@ export function readPullRequestDetailSnapshot( reference: PullRequestDetailSnapshotRef, ): PullRequestDetail | null { try { - const raw = storage?.getItem(pullRequestDetailSnapshotKey(environmentId, reference)); + const raw = + storage?.getItem(pullRequestDetailSnapshotKey(environmentId, reference)) ?? + (reference.host === undefined + ? null + : storage?.getItem( + pullRequestDetailSnapshotKey(environmentId, { ...reference, host: undefined }), + )); if (!raw) return null; const decoded = decodeDetailSnapshot(JSON.parse(raw)); return decoded._tag === "Some" @@ -1157,14 +1174,22 @@ export function resolveDisplayedPullRequestDetail(input: { }): PullRequestDetail | null { if (input.live !== null) return input.live; if ( - input.cached !== null && - input.cached.projectId === input.reference.projectId && - input.cached.repository.toLowerCase() === input.reference.repository.toLowerCase() && - input.cached.number === input.reference.number && - (input.reference.host === undefined || - parseChangeRequestUrl(input.cached.url)?.host === input.reference.host.toLowerCase()) + input.cached === null || + input.cached.projectId !== input.reference.projectId || + input.cached.repository.toLowerCase() !== input.reference.repository.toLowerCase() || + input.cached.number !== input.reference.number ) { - return input.cached; + return null; + } + if (input.reference.host === undefined) return input.cached; + try { + const url = new URL(input.cached.url); + const host = input.cached.provider === "forgejo" ? url.host : url.hostname; + return (url.protocol === "https:" || url.protocol === "http:") && + host.toLowerCase() === input.reference.host.toLowerCase() + ? input.cached + : null; + } catch { + return null; } - return null; } diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.test.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.test.tsx new file mode 100644 index 000000000000..df38d75152cf --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.test.tsx @@ -0,0 +1,28 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +import { PullRequestActorAvatar } from "./pullRequestPresentation"; + +let renderer: ReactTestRenderer | undefined; + +afterEach(async () => { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +it("falls back to the actor initial when a remote avatar fails", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + await act(async () => { + renderer = create( + , + ); + }); + + await act(async () => renderer!.root.findByType("img").props.onError()); + + expect(renderer!.root.findAllByType("img")).toHaveLength(0); + expect(renderer!.root.findByType("span").children).toEqual(["O"]); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 4792cca95ea7..65caae761ced 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -14,7 +14,7 @@ import { CircleXIcon, UserCheckIcon, } from "lucide-react"; -import { Children, isValidElement, type ReactNode } from "react"; +import { Children, isValidElement, type ReactNode, useState } from "react"; import { cn } from "~/lib/utils"; @@ -352,8 +352,9 @@ export function PullRequestActorAvatar({ }) { const login = actor?.login ?? "ghost"; const avatarUrl = actor?.avatarUrl ?? null; - return avatarUrl === null ? ( - // Not every host reports an avatar, so the initial stands in where none arrives. + const [failedAvatarUrl, setFailedAvatarUrl] = useState(null); + return avatarUrl === null || failedAvatarUrl === avatarUrl ? ( + // Not every host reports an avatar, and a private host may refuse the browser's request. setFailedAvatarUrl(avatarUrl)} /> ); } @@ -422,7 +424,10 @@ export function PullRequestActorLabel({ > {label} - {profileUrl ? `Open ${login}'s profile` : login} + + {actor?.name && actor.name !== login ? `${actor.name} (@${login})` : login} + {profileUrl ? " · Open profile" : ""} + ); } diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index d02f15939c5e..bac46a56d002 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -9,6 +9,7 @@ import { TriangleAlertIcon, XIcon, } from "lucide-react"; +import { useLocation } from "@tanstack/react-router"; import { type KeyboardEvent, type ReactNode, @@ -65,7 +66,7 @@ import { whenNodeRemoveLabel, } from "./KeybindingsSettings.logic"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; -import { searchableSetting } from "./settingsSearch"; +import { keybindingSearchAnchorId, searchableSetting } from "./settingsSearch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -823,7 +824,11 @@ interface KeybindingRowActions { onRemove: (row: KeybindingRow) => void; } -type KeybindingRowProps = KeybindingRowActions & { row: KeybindingRow; isSaving: boolean }; +type KeybindingRowProps = KeybindingRowActions & { + row: KeybindingRow; + isSaving: boolean; + anchorId?: string | undefined; +}; /** Shortcut pill that turns into a capture input when clicked, plus Save once the draft changes. */ function KeybindingKeyControl({ @@ -1034,11 +1039,12 @@ function KeybindingHoverRowMenu(props: { /** One binding as a settings row: pills flush right, actions fading in beside them on hover. */ function KeybindingSettingsRow(props: KeybindingRowProps) { - const { row, isSaving, allRows, variables, onSave, onReset, onRemove } = props; + const { row, isSaving, anchorId, allRows, variables, onSave, onReset, onRemove } = props; const editor = useKeybindingRowEditor({ row, allRows, onSave }); return ( } description={} @@ -1296,6 +1302,17 @@ function KeybindingsList(props: KeybindingsListProps) { onSave: rowActions.onSave, onCancel: onCancelAdd, }; + // Settings search jumps to a command, so only its first row anchors. + const anchorIds = useMemo(() => { + const ids = new Map(); + const seen = new Set(); + for (const row of rows) { + if (seen.has(row.command)) continue; + seen.add(row.command); + ids.set(row.id, keybindingSearchAnchorId(row.command)); + } + return ids; + }, [rows]); return (
{isAddingBinding ? : null} @@ -1303,6 +1320,7 @@ function KeybindingsList(props: KeybindingsListProps) { @@ -1357,6 +1375,16 @@ export function KeybindingsSettingsPanel() { const [savingCommand, setSavingCommand] = useState(null); const [isAddingBinding, setIsAddingBinding] = useState(false); const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]); + // The search-target context is provided by this panel's own page container, + // so the jump target is read from the route hash here. + const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") }); + const [handledSearchTargetId, setHandledSearchTargetId] = useState(searchTargetId); + + // A settings-search jump must not be hidden by the page's own filter. + if (searchTargetId !== handledSearchTargetId) { + setHandledSearchTargetId(searchTargetId); + if (searchTargetId.startsWith("keybinding-")) setQuery(""); + } const commandOptions = useMemo(() => buildKeybindingCommandOptions(keybindings), [keybindings]); const whenVariables = useMemo(() => buildWhenVariableOptions(), []); diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 46b837a7e621..2d64538d5c46 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -10,7 +10,7 @@ import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; // The font previews are the real surfaces, not lookalikes: the composer's -// Lexical editor, the diff panel's file diff, and the Ghostty canvas +// Tiptap editor, the diff panel's file diff, and the Ghostty canvas // renderer. Each already consumes the appearance font tokens (or, for the // terminal, the settings passed down as props), so what the row shows is // exactly what the app renders. diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 430852757f46..4fb847df684b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -105,7 +105,6 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; -import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Dialog, @@ -580,6 +579,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll ? ["Collapse composer on scroll"] : []), + ...(settings.composerRichTextEnabled !== DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled + ? ["Rich text composer"] + : []), ...(settings.sendShortcut !== DEFAULT_UNIFIED_SETTINGS.sendShortcut ? ["Send shortcut"] : []), ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior ? ["Follow-up behavior"] @@ -642,6 +644,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.confirmThreadUnpin, settings.composerCollapseOnScroll, + settings.composerRichTextEnabled, settings.sendShortcut, settings.followUpBehavior, settings.addProjectBaseDirectory, @@ -756,6 +759,7 @@ export function useSettingsRestore(onRestored?: () => void) { proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, + composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled, sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut, followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, @@ -2528,7 +2532,7 @@ export function GeneralSettingsPanel() { + + updateSettings({ + composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled, + }) + } + /> + ) : null + } + control={ + + updateSettings({ composerRichTextEnabled: Boolean(checked) }) + } + aria-label="Rich text composer" + /> + } + /> + { - const value = values[0]; + } /> @@ -3188,7 +3220,7 @@ export function GeneralSettingsPanel() { control={