Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 43 additions & 0 deletions PATCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,49 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera
ownership, ordinary failures do not abort the batch, and failed/unprocessed threads stay
selected. Navigation and worktree-cleanup failures are reported separately from a completed
deletion, including the fork's archived-thread deletion path.
- The 2026-09-10 sync (`e16b8b059c..0f602b3372`, 13 upstream commits) carries seven
independent changes and retains the following boundaries:
- Linked-PR search (`f0401c6290`) runs on the existing V2 `linkedPullRequest` in web's
sidebar and command palette. The shared `threadPullRequestSearchTerms` exposes the PR
number, repository and URL without host reads. It does not introduce the upstream
multi-link projection, title snapshots or frozen Expo changes.
- Image zoom/pan (`8d8189e67d`) uses the fork's `ExpandedImageDialog` gallery, keeping its
original download handling. Zoom resets on navigation, arrow keys pan while zoomed,
and modal presence blocks type-to-focus. The standalone image component tolerates SSR.
- Composer model labels use available width (`b7b3ef1e6f`, web half); touch devices expose
user-message copy controls (`385cc0a4c6`, assistant controls were already visible).
PR list diff counts move to the title's trailing edge (`addfb1390e`) within the existing
row layout; review/check metadata already lives on the second line.
- Linux/BSD middle-click pastes the terminal's own selection (`d1eeb16247`) through the
existing paste race/bracketed-paste path. VT mouse reporting keeps priority; the fork's
modifier-click links, native copy, selection and split-pane activation remain intact.
Zed remote SSH links (`0f602b3372`) use the shared editor catalog and the fork's Electron
external-link validator. No new runtime capability or migration is needed.
- Duplicate-command expansion (`50f918c57a`) is already covered by V2's
`buildToolCallExpandedBody` / projected-item disclosure; the fork has no
`commandMatchesVisibleLabel` expansion guard. Android feed positioning (`75e4ceb964`)
and glass backing (`383cc40f4d`) remain excluded under the Expo freeze.
- The approved native parity follow-up now supports multiple explicit PR links on V2:
`thread.metadata.update` adds/removes one link atomically, with a 50-link limit and
host/repository/number identity. The JSON projection carries `linkedPullRequests` while
`linkedPullRequest` remains the primary for older clients. Legacy edits preserve other links.
Swift gates collection editing on `threadPullRequestsV2`, searches every link, and requires
every linked PR to read as terminal before settling. Link changes restart its observations.
Web/Expo still render the primary and conservatively avoid automatic settlement for collections.
Automatic discovery/linking after creation, stack-dismissal tombstones, cached snapshots and
credential-scoped MCP link tools remain unported. Upstream's `threadPullRequests` flag and V1
commands stay excluded; `050_ProjectionThreadPullRequests` is dropped, with no new migration.
- GitHub stack navigation/merge/rebase (`de37964db2`) is now available to Swift through
`pullRequests.stack` and `pullRequestStackActions`. The standalone GitHub action implementation
retains reviewed-head checks, per-branch permissions, partial-rebase reporting and remote-only
operations. Confirmation holds the reviewed stack immutable; mutations invalidate every
reviewed PR's cached reads even after partial failure. Web/Expo stack controls remain unported.
- Restart-persistent PR summary/stack reads (`33242d0164`) remain excluded. Stack reads are
on demand; the earlier V2 background PR-discovery/summary service is still missing. Carry a
durable read cache with that service, including expiry and mutation/in-flight invalidation.
Advancing this sync marker records review of deferred work, not full upstream feature support.
- Swift's existing image galleries now support pinch/pan, double-tap zoom and an accessible
fit action while retaining original-byte export and current/adjacent-page loading.
- The 2026-09-09 sync (`223ff4490f..e16b8b059c`, 185 upstream commits) manually carries
independent correctness fixes while retaining the boundaries above:
- `thread.stop` (`09e8de9c65`) uses web/desktop's existing V2 `interruptThreadTurn` path.
Expand Down
30 changes: 29 additions & 1 deletion apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,33 @@ describe("ElectronShell", () => {
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("opens Zed's ssh deep link", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);

const electronShell = yield* ElectronShell.ElectronShell;
const result = yield* electronShell.openExternal("zed://ssh/example.com/home/user/project");

assert.equal(result, true);
assert.deepEqual(openExternalMock.mock.calls, [["zed://ssh/example.com/home/user/project"]]);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("does not open editor URLs that mix up link shapes", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);

const electronShell = yield* ElectronShell.ElectronShell;
const results = yield* Effect.all([
electronShell.openExternal("zed://extension/attacker"),
electronShell.openExternal("vscode://ssh/example.com/home/user/project"),
]);

assert.deepEqual(results, [false, false]);
assert.equal(openExternalMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("does not open remote editor URLs with userinfo", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);
Expand All @@ -64,9 +91,10 @@ describe("ElectronShell", () => {
electronShell.openExternal(
"vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project",
),
electronShell.openExternal("zed://ssh/user@example.com/home/user/project"),
]);

assert.deepEqual(results, [false, false]);
assert.deepEqual(results, [false, false, false]);
assert.equal(openExternalMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronShell.layer)),
);
Expand Down
15 changes: 10 additions & 5 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import * as Option from "effect/Option";

import * as Electron from "electron";

// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`)
// must reach the OS handler; every other non-web scheme stays blocked.
// Remote editor links use VS Code’s vscode-remote shape or Zed’s ssh shape.
// Other non-web schemes stay blocked.
const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);
const REMOTE_EDITOR_PROTOCOLS = new Set(
REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => {
Expand All @@ -16,13 +16,18 @@ const REMOTE_EDITOR_PROTOCOLS = new Set(
}),
);

// Zed's host sits in the first path segment, so it needs its own userinfo ban.
const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.+$/;

const isRemoteEditorUrl = (url: URL) =>
REMOTE_EDITOR_PROTOCOLS.has(url.protocol) &&
url.username.length === 0 &&
url.password.length === 0 &&
url.host === "vscode-remote" &&
url.pathname.startsWith("/ssh-remote+") &&
url.pathname.length > "/ssh-remote+".length;
(url.protocol === "zed:"
? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname)
: url.host === "vscode-remote" &&
url.pathname.startsWith("/ssh-remote+") &&
url.pathname.length > "/ssh-remote+".length);

export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
if (typeof rawUrl !== "string") {
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope,
[WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ export const make = Effect.gen(function* () {
threadPinReorder: true,
threadTitleRegeneration: true,
threadPullRequestLinking: true,
threadPullRequestsV2: true,
pullRequestStackActions: true,
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),
},
Expand Down
25 changes: 23 additions & 2 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { updateLinkedPullRequests } from "@t3tools/shared/threadPullRequests";
import {
type ChatAttachment,
CommandId,
Expand Down Expand Up @@ -1449,6 +1450,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
),
);
const thread = projection.thread;
if (command.type === "thread.metadata.update") {
const edits = [
command.linkedPullRequest,
command.linkPullRequest,
command.unlinkPullRequest,
].filter((value) => value !== undefined);
if (
edits.length > 1 ||
updateLinkedPullRequests(thread, command).linkedPullRequests.length > 50
) {
return yield* new OrchestratorDispatchError({
commandId: command.commandId,
commandType: command.type,
cause:
"Send one pull-request edit at a time; a thread can link at most 50 pull requests.",
});
}
}
if (thread.deletedAt !== null && command.type !== "thread.delete") {
return yield* new OrchestratorDispatchError({
commandId: command.commandId,
Expand Down Expand Up @@ -1730,9 +1749,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
? {}
: { activeOrderKey: command.activeOrderKey }),
// Absent leaves the link alone; null unlinks.
...(command.linkedPullRequest === undefined
...(command.linkedPullRequest === undefined &&
command.linkPullRequest === undefined &&
command.unlinkPullRequest === undefined
? {}
: { linkedPullRequest: command.linkedPullRequest }),
: updateLinkedPullRequests(thread, command)),
...(command.workInboxRole === undefined
? {}
: {
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,32 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => {
payload: thread,
});

const links = [41, 42].map((number) => ({
projectId,
repository: "owner/repo",
number,
url: `https://github.com/owner/repo/pull/${number}`,
}));
yield* projectionStore.apply({
id: EventId.make("event:projection-read-state:links"),
type: "thread.metadata-updated",
threadId,
occurredAt: markedUnreadOccurredAt,
payload: { ...thread, linkedPullRequest: links[0]!, linkedPullRequests: links },
});
assert.deepEqual(
(yield* projectionStore.getThreadProjection(threadId)).thread.linkedPullRequests,
links,
);
assert.deepEqual(
(yield* projectionStore.getThreadShell(threadId))?.linkedPullRequests,
links,
);
assert.deepEqual(
(yield* projectionStore.getShellSnapshot()).threads.find((shell) => shell.id === threadId)
?.linkedPullRequests,
links,
);
const markedUnread = yield* projectionStore.getThreadProjection(threadId);
assert.isNull(markedUnread.thread.lastVisitedAt);
assert.deepEqual(markedUnread.thread.updatedAt, createdAt);
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,9 @@ export function threadShellFromProjection(
? {}
: { worktreeStatus: projection.thread.worktreeStatus }),
linkedPullRequest: projection.thread.linkedPullRequest ?? null,
...(projection.thread.linkedPullRequests === undefined
? {}
: { linkedPullRequests: projection.thread.linkedPullRequests }),
lineage: projection.thread.lineage,
forkedFrom: projection.thread.forkedFrom,
activeProviderThreadId: projection.thread.activeProviderThreadId,
Expand Down Expand Up @@ -1162,6 +1165,9 @@ function shellFromState(input: {
? {}
: { worktreeStatus: input.state.thread.worktreeStatus }),
linkedPullRequest: input.state.thread.linkedPullRequest ?? null,
...(input.state.thread.linkedPullRequests === undefined
? {}
: { linkedPullRequests: input.state.thread.linkedPullRequests }),
lineage: input.state.thread.lineage,
forkedFrom: input.state.thread.forkedFrom,
activeProviderThreadId: input.state.thread.activeProviderThreadId,
Expand Down
82 changes: 82 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestCli.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
import {
decodePullRequestStacksJson,
type GitHubPullRequestStack,
} from "./gitHubPullRequestJson.ts";
import type { PullRequestStackHead } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -241,6 +247,7 @@ export class GitHubSubjectScopeError extends Schema.TaggedErrorClass<GitHubSubje
}

export type GitHubPullRequestCliError =
| GitHubStackActionError
| GitHubCli.GitHubCliError
| GitHubPullRequestReadError
| GitHubDiffCursorError
Expand Down Expand Up @@ -477,12 +484,21 @@ export class GitHubPullRequestCli extends Context.Service<
readonly requested: boolean;
}) => Effect.Effect<void, GitHubPullRequestCliError>;

readonly getPullRequestStack: (input: {
readonly cwd: string;
readonly repository: string;
readonly host: string;
readonly number: number;
readonly includeDetails?: boolean;
}) => Effect.Effect<GitHubPullRequestStack | null, GitHubPullRequestCliError>;
readonly runPullRequestAction: (input: {
readonly cwd: string;
readonly repository: string;
readonly host: string;
readonly number: number;
readonly action: PullRequestAction;
readonly stackNumber?: number;
readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
readonly mergeMethod?: PullRequestMergeMethod;
readonly updateMethod?: PullRequestUpdateMethod;
}) => Effect.Effect<void, GitHubPullRequestCliError>;
Expand Down Expand Up @@ -1696,7 +1712,73 @@ export const make = Effect.gen(function* () {
.pipe(Effect.asVoid);
},

getPullRequestStack: (input) => {
const { owner, name } = parseRepositorySelector(input.repository);
return github
.execute({
cwd: input.cwd,
args: [
"api",
"--hostname",
input.host,
`repos/${owner}/${name}/stacks?pull_request=${input.number}`,
],
})
.pipe(
Effect.flatMap((result) => {
const decoded = decodePullRequestStacksJson(result.stdout.trim());
return Result.isSuccess(decoded)
? Effect.succeed(decoded.success)
: Effect.fail(
new GitHubPullRequestReadError({
command: "gh",
cwd: input.cwd,
operation: "getPullRequestStack",
cause: decoded.failure,
}),
);
}),
Effect.flatMap((stack) => {
if (!input.includeDetails || stack === null) return Effect.succeed(stack);
return github
.execute({
cwd: input.cwd,
args: [
"api",
"--hostname",
input.host,
`repos/${owner}/${name}/stacks/${stack.number}`,
],
})
.pipe(
Effect.flatMap((result) => {
const decoded = decodePullRequestStacksJson(`[${result.stdout.trim()}]`);
return Result.isSuccess(decoded)
? Effect.succeed(decoded.success)
: Effect.fail(
new GitHubPullRequestReadError({
command: "gh",
cwd: input.cwd,
operation: "getPullRequestStack",
cause: decoded.failure,
}),
);
}),
);
}),
// Hosts without the stacks preview return 404. Other failures must preserve the
// previously synced stack and let the caller retry.
Effect.catchTags({
GitHubPullRequestNotFoundError: () => Effect.succeed(null),
}),
);
},

runPullRequestAction: (input) => {
if (input.stackNumber !== undefined)
return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe(
Effect.provideService(GitHubCli.GitHubCli, github),
);
const [subcommand, ...flags] = actionArgs(
input.action,
input.mergeMethod,
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,10 @@ export const make = Effect.gen(function* () {
})
.pipe(Effect.mapError(fail("setReviewerRequest"))),

getStack: (input) =>
cli
.getPullRequestStack({ ...input, includeDetails: true })
.pipe(Effect.mapError(fail("getStack"))),
runAction: (input) =>
cli
.runPullRequestAction({
Expand All @@ -422,6 +426,10 @@ export const make = Effect.gen(function* () {
host: input.host,
number: input.number,
action: input.action,
...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),
...(input.expectedStackHeads === undefined
? {}
: { expectedStackHeads: input.expectedStackHeads }),
...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }),
})
Expand Down
Loading
Loading