Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d2d5bba
feat(web): pull request files can be marked as viewed
yordis Aug 20, 2026
cf5ac4c
fix(server): an overpriced write guess no longer pauses reads until t…
yordis Aug 20, 2026
e62d906
fix(web): a failed press no longer takes back a tick the reader made …
yordis Aug 20, 2026
19a679c
fix(web): a viewed tick redraws its file, and a partial count says th…
yordis Aug 20, 2026
447fd19
style: plainer punctuation in the viewed files comments
yordis Aug 20, 2026
d085d08
fix(web): pressing the word beside the box no longer folds the file t…
yordis Aug 20, 2026
4891bcd
fix(server): a write no longer guesses at a budget only the host can …
yordis Aug 20, 2026
dad689d
fix(web): a failed request no longer answers for a press a later one …
yordis Aug 20, 2026
9628643
fix(web): a viewed tick no longer rebuilds every header on screen
yordis Aug 20, 2026
6b44e51
fix(web): the viewed box says what it is for out loud
yordis Aug 20, 2026
28eea17
Merge upstream main into yordis/feat-pr-files-viewed-upstream
yordis Aug 27, 2026
aa7a828
fix(web): a refreshed review re-asks for the ticks
yordis Aug 28, 2026
5f08047
fix(web): a superseded write no longer reports a failure
yordis Aug 28, 2026
3c279bf
fix(web): a dropped connection no longer reports a rejected write
yordis Aug 28, 2026
f5c7f62
feat(server): GitLab reviewers can keep their place in a long merge r…
yordis Aug 30, 2026
53253a2
fix(web): a GitLab reader can tell whose viewed marks these are
yordis Aug 30, 2026
1de680d
fix(web): Azure DevOps pull request links open in the app
yordis Aug 30, 2026
0876415
feat(server): Bitbucket reviewers keep their place in a long review
yordis Aug 30, 2026
9146a44
feat(server): Azure DevOps pull requests show their files
yordis Aug 30, 2026
bbf04bb
perf(server): a review's ticks stop waiting on the host
yordis Aug 30, 2026
cd7cccc
fix(server): a long or unreadable Azure change still renders its diff
yordis Aug 30, 2026
75c0bb8
fix(server): a review's ticks survive what the host could not read
yordis Aug 30, 2026
a95bf64
fix(server): a part-read Azure change no longer passes as the whole o…
yordis Aug 30, 2026
644839c
fix(server): a diff given up on no longer costs the whole slice again…
yordis Aug 30, 2026
b060805
fix(server): a rate-limited Azure diff no longer reads as a change wi…
yordis Aug 31, 2026
6e9d61c
fix(web): the viewed count no longer pushes the code toolbar off the …
yordis Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/persistence/Errors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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
`;
});
156 changes: 156 additions & 0 deletions apps/server/src/persistence/PullRequestFilesViewed.ts
Original file line numberDiff line numberDiff line change
@@ -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<PullRequestFileViewedMark & { readonly viewed: boolean }>;
/** 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<PullRequestFileViewedMark>,
PullRequestFilesViewedRepositoryError
>;
readonly set: (
input: SetPullRequestFilesViewedInput,
) => Effect.Effect<void, PullRequestFilesViewedRepositoryError>;
}
>()("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);
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d2d5bba
feat(web): pull request files can be marked as viewed
yordis Aug 20, 2026
cf5ac4c
fix(server): an overpriced write guess no longer pauses reads until t…
yordis Aug 20, 2026
e62d906
fix(web): a failed press no longer takes back a tick the reader made …
yordis Aug 20, 2026
19a679c
fix(web): a viewed tick redraws its file, and a partial count says th…
yordis Aug 20, 2026
447fd19
style: plainer punctuation in the viewed files comments
yordis Aug 20, 2026
d085d08
fix(web): pressing the word beside the box no longer folds the file t…
yordis Aug 20, 2026
4891bcd
fix(server): a write no longer guesses at a budget only the host can …
yordis Aug 20, 2026
dad689d
fix(web): a failed request no longer answers for a press a later one …
yordis Aug 20, 2026
9628643
fix(web): a viewed tick no longer rebuilds every header on screen
yordis Aug 20, 2026
6b44e51
fix(web): the viewed box says what it is for out loud
yordis Aug 20, 2026
28eea17
Merge upstream main into yordis/feat-pr-files-viewed-upstream
yordis Aug 27, 2026
aa7a828
fix(web): a refreshed review re-asks for the ticks
yordis Aug 28, 2026
5f08047
fix(web): a superseded write no longer reports a failure
yordis Aug 28, 2026
3c279bf
fix(web): a dropped connection no longer reports a rejected write
yordis Aug 28, 2026
f5c7f62
feat(server): GitLab reviewers can keep their place in a long merge r…
yordis Aug 30, 2026
53253a2
fix(web): a GitLab reader can tell whose viewed marks these are
yordis Aug 30, 2026
1de680d
fix(web): Azure DevOps pull request links open in the app
yordis Aug 30, 2026
0876415
feat(server): Bitbucket reviewers keep their place in a long review
yordis Aug 30, 2026
9146a44
feat(server): Azure DevOps pull requests show their files
yordis Aug 30, 2026
bbf04bb
perf(server): a review's ticks stop waiting on the host
yordis Aug 30, 2026
cd7cccc
fix(server): a long or unreadable Azure change still renders its diff
yordis Aug 30, 2026
75c0bb8
fix(server): a review's ticks survive what the host could not read
yordis Aug 30, 2026
a95bf64
fix(server): a part-read Azure change no longer passes as the whole o…
yordis Aug 30, 2026
644839c
fix(server): a diff given up on no longer costs the whole slice again…
yordis Aug 30, 2026
b060805
fix(server): a rate-limited Azure diff no longer reads as a change wi…
yordis Aug 31, 2026
6e9d61c
fix(web): the viewed count no longer pushes the code toolbar off the …
yordis Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/persistence/Errors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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
`;
});
156 changes: 156 additions & 0 deletions apps/server/src/persistence/PullRequestFilesViewed.ts
Original file line numberDiff line numberDiff line change
@@ -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<PullRequestFileViewedMark & { readonly viewed: boolean }>;
/** 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<PullRequestFileViewedMark>,
PullRequestFilesViewedRepositoryError
>;
readonly set: (
input: SetPullRequestFilesViewedInput,
) => Effect.Effect<void, PullRequestFilesViewedRepositoryError>;
}
>()("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);
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d2d5bba
feat(web): pull request files can be marked as viewed
yordis Aug 20, 2026
cf5ac4c
fix(server): an overpriced write guess no longer pauses reads until t…
yordis Aug 20, 2026
e62d906
fix(web): a failed press no longer takes back a tick the reader made …
yordis Aug 20, 2026
19a679c
fix(web): a viewed tick redraws its file, and a partial count says th…
yordis Aug 20, 2026
447fd19
style: plainer punctuation in the viewed files comments
yordis Aug 20, 2026
d085d08
fix(web): pressing the word beside the box no longer folds the file t…
yordis Aug 20, 2026
4891bcd
fix(server): a write no longer guesses at a budget only the host can …
yordis Aug 20, 2026
dad689d
fix(web): a failed request no longer answers for a press a later one …
yordis Aug 20, 2026
9628643
fix(web): a viewed tick no longer rebuilds every header on screen
yordis Aug 20, 2026
6b44e51
fix(web): the viewed box says what it is for out loud
yordis Aug 20, 2026
28eea17
Merge upstream main into yordis/feat-pr-files-viewed-upstream
yordis Aug 27, 2026
aa7a828
fix(web): a refreshed review re-asks for the ticks
yordis Aug 28, 2026
5f08047
fix(web): a superseded write no longer reports a failure
yordis Aug 28, 2026
3c279bf
fix(web): a dropped connection no longer reports a rejected write
yordis Aug 28, 2026
f5c7f62
feat(server): GitLab reviewers can keep their place in a long merge r…
yordis Aug 30, 2026
53253a2
fix(web): a GitLab reader can tell whose viewed marks these are
yordis Aug 30, 2026
1de680d
fix(web): Azure DevOps pull request links open in the app
yordis Aug 30, 2026
0876415
feat(server): Bitbucket reviewers keep their place in a long review
yordis Aug 30, 2026
9146a44
feat(server): Azure DevOps pull requests show their files
yordis Aug 30, 2026
bbf04bb
perf(server): a review's ticks stop waiting on the host
yordis Aug 30, 2026
cd7cccc
fix(server): a long or unreadable Azure change still renders its diff
yordis Aug 30, 2026
75c0bb8
fix(server): a review's ticks survive what the host could not read
yordis Aug 30, 2026
a95bf64
fix(server): a part-read Azure change no longer passes as the whole o…
yordis Aug 30, 2026
644839c
fix(server): a diff given up on no longer costs the whole slice again…
yordis Aug 30, 2026
b060805
fix(server): a rate-limited Azure diff no longer reads as a change wi…
yordis Aug 31, 2026
6e9d61c
fix(web): the viewed count no longer pushes the code toolbar off the …
yordis Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/persistence/Errors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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
`;
});
156 changes: 156 additions & 0 deletions apps/server/src/persistence/PullRequestFilesViewed.ts
Original file line numberDiff line numberDiff line change
@@ -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<PullRequestFileViewedMark & { readonly viewed: boolean }>;
/** 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<PullRequestFileViewedMark>,
PullRequestFilesViewedRepositoryError
>;
readonly set: (
input: SetPullRequestFilesViewedInput,
) => Effect.Effect<void, PullRequestFilesViewedRepositoryError>;
}
>()("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);
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d2d5bba
feat(web): pull request files can be marked as viewed
yordis Aug 20, 2026
cf5ac4c
fix(server): an overpriced write guess no longer pauses reads until t…
yordis Aug 20, 2026
e62d906
fix(web): a failed press no longer takes back a tick the reader made …
yordis Aug 20, 2026
19a679c
fix(web): a viewed tick redraws its file, and a partial count says th…
yordis Aug 20, 2026
447fd19
style: plainer punctuation in the viewed files comments
yordis Aug 20, 2026
d085d08
fix(web): pressing the word beside the box no longer folds the file t…
yordis Aug 20, 2026
4891bcd
fix(server): a write no longer guesses at a budget only the host can …
yordis Aug 20, 2026
dad689d
fix(web): a failed request no longer answers for a press a later one …
yordis Aug 20, 2026
9628643
fix(web): a viewed tick no longer rebuilds every header on screen
yordis Aug 20, 2026
6b44e51
fix(web): the viewed box says what it is for out loud
yordis Aug 20, 2026
28eea17
Merge upstream main into yordis/feat-pr-files-viewed-upstream
yordis Aug 27, 2026
aa7a828
fix(web): a refreshed review re-asks for the ticks
yordis Aug 28, 2026
5f08047
fix(web): a superseded write no longer reports a failure
yordis Aug 28, 2026
3c279bf
fix(web): a dropped connection no longer reports a rejected write
yordis Aug 28, 2026
f5c7f62
feat(server): GitLab reviewers can keep their place in a long merge r…
yordis Aug 30, 2026
53253a2
fix(web): a GitLab reader can tell whose viewed marks these are
yordis Aug 30, 2026
1de680d
fix(web): Azure DevOps pull request links open in the app
yordis Aug 30, 2026
0876415
feat(server): Bitbucket reviewers keep their place in a long review
yordis Aug 30, 2026
9146a44
feat(server): Azure DevOps pull requests show their files
yordis Aug 30, 2026
bbf04bb
perf(server): a review's ticks stop waiting on the host
yordis Aug 30, 2026
cd7cccc
fix(server): a long or unreadable Azure change still renders its diff
yordis Aug 30, 2026
75c0bb8
fix(server): a review's ticks survive what the host could not read
yordis Aug 30, 2026
a95bf64
fix(server): a part-read Azure change no longer passes as the whole o…
yordis Aug 30, 2026
644839c
fix(server): a diff given up on no longer costs the whole slice again…
yordis Aug 30, 2026
b060805
fix(server): a rate-limited Azure diff no longer reads as a change wi…
yordis Aug 31, 2026
6e9d61c
fix(web): the viewed count no longer pushes the code toolbar off the …
yordis Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/persistence/Errors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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
`;
});
156 changes: 156 additions & 0 deletions apps/server/src/persistence/PullRequestFilesViewed.ts
Original file line numberDiff line numberDiff line change
@@ -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<PullRequestFileViewedMark & { readonly viewed: boolean }>;
/** 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<PullRequestFileViewedMark>,
PullRequestFilesViewedRepositoryError
>;
readonly set: (
input: SetPullRequestFilesViewedInput,
) => Effect.Effect<void, PullRequestFilesViewedRepositoryError>;
}
>()("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);
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d2d5bba
feat(web): pull request files can be marked as viewed
yordis Aug 20, 2026
cf5ac4c
fix(server): an overpriced write guess no longer pauses reads until t…
yordis Aug 20, 2026
e62d906
fix(web): a failed press no longer takes back a tick the reader made …
yordis Aug 20, 2026
19a679c
fix(web): a viewed tick redraws its file, and a partial count says th…
yordis Aug 20, 2026
447fd19
style: plainer punctuation in the viewed files comments
yordis Aug 20, 2026
d085d08
fix(web): pressing the word beside the box no longer folds the file t…
yordis Aug 20, 2026
4891bcd
fix(server): a write no longer guesses at a budget only the host can …
yordis Aug 20, 2026
dad689d
fix(web): a failed request no longer answers for a press a later one …
yordis Aug 20, 2026
9628643
fix(web): a viewed tick no longer rebuilds every header on screen
yordis Aug 20, 2026
6b44e51
fix(web): the viewed box says what it is for out loud
yordis Aug 20, 2026
28eea17
Merge upstream main into yordis/feat-pr-files-viewed-upstream
yordis Aug 27, 2026
aa7a828
fix(web): a refreshed review re-asks for the ticks
yordis Aug 28, 2026
5f08047
fix(web): a superseded write no longer reports a failure
yordis Aug 28, 2026
3c279bf
fix(web): a dropped connection no longer reports a rejected write
yordis Aug 28, 2026
f5c7f62
feat(server): GitLab reviewers can keep their place in a long merge r…
yordis Aug 30, 2026
53253a2
fix(web): a GitLab reader can tell whose viewed marks these are
yordis Aug 30, 2026
1de680d
fix(web): Azure DevOps pull request links open in the app
yordis Aug 30, 2026
0876415
feat(server): Bitbucket reviewers keep their place in a long review
yordis Aug 30, 2026
9146a44
feat(server): Azure DevOps pull requests show their files
yordis Aug 30, 2026
bbf04bb
perf(server): a review's ticks stop waiting on the host
yordis Aug 30, 2026
cd7cccc
fix(server): a long or unreadable Azure change still renders its diff
yordis Aug 30, 2026
75c0bb8
fix(server): a review's ticks survive what the host could not read
yordis Aug 30, 2026
a95bf64
fix(server): a part-read Azure change no longer passes as the whole o…
yordis Aug 30, 2026
644839c
fix(server): a diff given up on no longer costs the whole slice again…
yordis Aug 30, 2026
b060805
fix(server): a rate-limited Azure diff no longer reads as a change wi…
yordis Aug 31, 2026
6e9d61c
fix(web): the viewed count no longer pushes the code toolbar off the …
yordis Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/persistence/Errors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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
`;
});
156 changes: 156 additions & 0 deletions apps/server/src/persistence/PullRequestFilesViewed.ts
Original file line numberDiff line numberDiff line change
@@ -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<PullRequestFileViewedMark & { readonly viewed: boolean }>;
/** 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<PullRequestFileViewedMark>,
PullRequestFilesViewedRepositoryError
>;
readonly set: (
input: SetPullRequestFilesViewedInput,
) => Effect.Effect<void, PullRequestFilesViewedRepositoryError>;
}
>()("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);
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d2d5bba
feat(web): pull request files can be marked as viewed
yordis Aug 20, 2026
cf5ac4c
fix(server): an overpriced write guess no longer pauses reads until t…
yordis Aug 20, 2026
e62d906
fix(web): a failed press no longer takes back a tick the reader made …
yordis Aug 20, 2026
19a679c
fix(web): a viewed tick redraws its file, and a partial count says th…
yordis Aug 20, 2026
447fd19
style: plainer punctuation in the viewed files comments
yordis Aug 20, 2026
d085d08
fix(web): pressing the word beside the box no longer folds the file t…
yordis Aug 20, 2026
4891bcd
fix(server): a write no longer guesses at a budget only the host can …
yordis Aug 20, 2026
dad689d
fix(web): a failed request no longer answers for a press a later one …
yordis Aug 20, 2026
9628643
fix(web): a viewed tick no longer rebuilds every header on screen
yordis Aug 20, 2026
6b44e51
fix(web): the viewed box says what it is for out loud
yordis Aug 20, 2026
28eea17
Merge upstream main into yordis/feat-pr-files-viewed-upstream
yordis Aug 27, 2026
aa7a828
fix(web): a refreshed review re-asks for the ticks
yordis Aug 28, 2026
5f08047
fix(web): a superseded write no longer reports a failure
yordis Aug 28, 2026
3c279bf
fix(web): a dropped connection no longer reports a rejected write
yordis Aug 28, 2026
f5c7f62
feat(server): GitLab reviewers can keep their place in a long merge r…
yordis Aug 30, 2026
53253a2
fix(web): a GitLab reader can tell whose viewed marks these are
yordis Aug 30, 2026
1de680d
fix(web): Azure DevOps pull request links open in the app
yordis Aug 30, 2026
0876415
feat(server): Bitbucket reviewers keep their place in a long review
yordis Aug 30, 2026
9146a44
feat(server): Azure DevOps pull requests show their files
yordis Aug 30, 2026
bbf04bb
perf(server): a review's ticks stop waiting on the host
yordis Aug 30, 2026
cd7cccc
fix(server): a long or unreadable Azure change still renders its diff
yordis Aug 30, 2026
75c0bb8
fix(server): a review's ticks survive what the host could not read
yordis Aug 30, 2026
a95bf64
fix(server): a part-read Azure change no longer passes as the whole o…
yordis Aug 30, 2026
644839c
fix(server): a diff given up on no longer costs the whole slice again…
yordis Aug 30, 2026
b060805
fix(server): a rate-limited Azure diff no longer reads as a change wi…
yordis Aug 31, 2026
6e9d61c
fix(web): the viewed count no longer pushes the code toolbar off the …
yordis Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/persistence/Errors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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
`;
});
156 changes: 156 additions & 0 deletions apps/server/src/persistence/PullRequestFilesViewed.ts
Original file line numberDiff line numberDiff line change
@@ -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<PullRequestFileViewedMark & { readonly viewed: boolean }>;
/** 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<PullRequestFileViewedMark>,
PullRequestFilesViewedRepositoryError
>;
readonly set: (
input: SetPullRequestFilesViewedInput,
) => Effect.Effect<void, PullRequestFilesViewedRepositoryError>;
}
>()("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);
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d2d5bba
feat(web): pull request files can be marked as viewed
yordis Aug 20, 2026
cf5ac4c
fix(server): an overpriced write guess no longer pauses reads until t…
yordis Aug 20, 2026
e62d906
fix(web): a failed press no longer takes back a tick the reader made …
yordis Aug 20, 2026
19a679c
fix(web): a viewed tick redraws its file, and a partial count says th…
yordis Aug 20, 2026
447fd19
style: plainer punctuation in the viewed files comments
yordis Aug 20, 2026
d085d08
fix(web): pressing the word beside the box no longer folds the file t…
yordis Aug 20, 2026
4891bcd
fix(server): a write no longer guesses at a budget only the host can …
yordis Aug 20, 2026
dad689d
fix(web): a failed request no longer answers for a press a later one …
yordis Aug 20, 2026
9628643
fix(web): a viewed tick no longer rebuilds every header on screen
yordis Aug 20, 2026
6b44e51
fix(web): the viewed box says what it is for out loud
yordis Aug 20, 2026
28eea17
Merge upstream main into yordis/feat-pr-files-viewed-upstream
yordis Aug 27, 2026
aa7a828
fix(web): a refreshed review re-asks for the ticks
yordis Aug 28, 2026
5f08047
fix(web): a superseded write no longer reports a failure
yordis Aug 28, 2026
3c279bf
fix(web): a dropped connection no longer reports a rejected write
yordis Aug 28, 2026
f5c7f62
feat(server): GitLab reviewers can keep their place in a long merge r…
yordis Aug 30, 2026
53253a2
fix(web): a GitLab reader can tell whose viewed marks these are
yordis Aug 30, 2026
1de680d
fix(web): Azure DevOps pull request links open in the app
yordis Aug 30, 2026
0876415
feat(server): Bitbucket reviewers keep their place in a long review
yordis Aug 30, 2026
9146a44
feat(server): Azure DevOps pull requests show their files
yordis Aug 30, 2026
bbf04bb
perf(server): a review's ticks stop waiting on the host
yordis Aug 30, 2026
cd7cccc
fix(server): a long or unreadable Azure change still renders its diff
yordis Aug 30, 2026
75c0bb8
fix(server): a review's ticks survive what the host could not read
yordis Aug 30, 2026
a95bf64
fix(server): a part-read Azure change no longer passes as the whole o…
yordis Aug 30, 2026
644839c
fix(server): a diff given up on no longer costs the whole slice again…
yordis Aug 30, 2026
b060805
fix(server): a rate-limited Azure diff no longer reads as a change wi…
yordis Aug 31, 2026
6e9d61c
fix(web): the viewed count no longer pushes the code toolbar off the …
yordis Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/persistence/Errors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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
`;
});
156 changes: 156 additions & 0 deletions apps/server/src/persistence/PullRequestFilesViewed.ts
Original file line numberDiff line numberDiff line change
@@ -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<PullRequestFileViewedMark & { readonly viewed: boolean }>;
/** 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<PullRequestFileViewedMark>,
PullRequestFilesViewedRepositoryError
>;
readonly set: (
input: SetPullRequestFilesViewedInput,
) => Effect.Effect<void, PullRequestFilesViewedRepositoryError>;
}
>()("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);
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d2d5bba
feat(web): pull request files can be marked as viewed
yordis Aug 20, 2026
cf5ac4c
fix(server): an overpriced write guess no longer pauses reads until t…
yordis Aug 20, 2026
e62d906
fix(web): a failed press no longer takes back a tick the reader made …
yordis Aug 20, 2026
19a679c
fix(web): a viewed tick redraws its file, and a partial count says th…
yordis Aug 20, 2026
447fd19
style: plainer punctuation in the viewed files comments
yordis Aug 20, 2026
d085d08
fix(web): pressing the word beside the box no longer folds the file t…
yordis Aug 20, 2026
4891bcd
fix(server): a write no longer guesses at a budget only the host can …
yordis Aug 20, 2026
dad689d
fix(web): a failed request no longer answers for a press a later one …
yordis Aug 20, 2026
9628643
fix(web): a viewed tick no longer rebuilds every header on screen
yordis Aug 20, 2026
6b44e51
fix(web): the viewed box says what it is for out loud
yordis Aug 20, 2026
28eea17
Merge upstream main into yordis/feat-pr-files-viewed-upstream
yordis Aug 27, 2026
aa7a828
fix(web): a refreshed review re-asks for the ticks
yordis Aug 28, 2026
5f08047
fix(web): a superseded write no longer reports a failure
yordis Aug 28, 2026
3c279bf
fix(web): a dropped connection no longer reports a rejected write
yordis Aug 28, 2026
f5c7f62
feat(server): GitLab reviewers can keep their place in a long merge r…
yordis Aug 30, 2026
53253a2
fix(web): a GitLab reader can tell whose viewed marks these are
yordis Aug 30, 2026
1de680d
fix(web): Azure DevOps pull request links open in the app
yordis Aug 30, 2026
0876415
feat(server): Bitbucket reviewers keep their place in a long review
yordis Aug 30, 2026
9146a44
feat(server): Azure DevOps pull requests show their files
yordis Aug 30, 2026
bbf04bb
perf(server): a review's ticks stop waiting on the host
yordis Aug 30, 2026
cd7cccc
fix(server): a long or unreadable Azure change still renders its diff
yordis Aug 30, 2026
75c0bb8
fix(server): a review's ticks survive what the host could not read
yordis Aug 30, 2026
a95bf64
fix(server): a part-read Azure change no longer passes as the whole o…
yordis Aug 30, 2026
644839c
fix(server): a diff given up on no longer costs the whole slice again…
yordis Aug 30, 2026
b060805
fix(server): a rate-limited Azure diff no longer reads as a change wi…
yordis Aug 31, 2026
6e9d61c
fix(web): the viewed count no longer pushes the code toolbar off the …
yordis Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/persistence/Errors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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
`;
});
156 changes: 156 additions & 0 deletions apps/server/src/persistence/PullRequestFilesViewed.ts
Original file line numberDiff line numberDiff line change
@@ -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<PullRequestFileViewedMark & { readonly viewed: boolean }>;
/** 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<PullRequestFileViewedMark>,
PullRequestFilesViewedRepositoryError
>;
readonly set: (
input: SetPullRequestFilesViewedInput,
) => Effect.Effect<void, PullRequestFilesViewedRepositoryError>;
}
>()("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);
Loading
Loading