Skip to content
Merged
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
21 changes: 9 additions & 12 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
import {
findSharedSettingsMismatches,
pickSharedServerSettings,
supportsSharedSettingsSync,
} from "@t3tools/client-runtime/state/shared-settings";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import {
Expand Down Expand Up @@ -553,9 +554,9 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD

/**
* Auto-settlement is a user preference that every server has to hold. Mobile
* has no primary environment, so the first connected environment that
* supports it is the reference value. Edits fan out to every connected
* environment, and a mismatch row lets the user push the reference out.
* has no primary environment, so the first eligible sync target provides the
* reference value. Edits fan out to every eligible target, and a mismatch row
* lets the user push the reference out.
*/
function AutoSettleSettingsRows() {
const { environments } = useEnvironments();
Expand All @@ -564,12 +565,8 @@ function AutoSettleSettingsRows() {
reportFailure: true,
});

const connected = environments.filter(
(environment) =>
environment.connection.phase === "connected" &&
environment.serverConfig?.environment.capabilities.threadAutoSettlement === true,
);
const reference = connected[0] ?? null;
const syncTargets = environments.filter(supportsSharedSettingsSync);
const reference = syncTargets[0] ?? null;
const referenceSettings = reference?.serverConfig?.settings ?? null;

const [daysDraft, setDaysDraft] = useState<string | null>(null);
Expand All @@ -579,7 +576,7 @@ function AutoSettleSettingsRows() {
}

const writeToAll = (patch: ServerSettingsPatch) => {
for (const environment of connected) {
for (const environment of syncTargets) {
void updateSettings({ environmentId: environment.environmentId, input: { patch } });
}
};
Expand All @@ -590,7 +587,7 @@ function AutoSettleSettingsRows() {
environments: environments.map((environment) => ({
environmentId: environment.environmentId,
label: environment.label,
connected: environment.connection.phase === "connected",
syncEligible: supportsSharedSettingsSync(environment),
settings: environment.serverConfig?.settings ?? null,
})),
});
Expand All @@ -600,7 +597,7 @@ function AutoSettleSettingsRows() {
const draft = (daysDraft ?? "").trim();
setDaysDraft(null);
// Whole-string check so "3.5" and "3days" are rejected instead of
// silently becoming 3 on every connected environment.
// silently becoming 3 on every eligible sync target.
const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN;
if (
Number.isInteger(parsed) &&
Expand Down
42 changes: 42 additions & 0 deletions apps/server/src/orchestration/ThreadSettlementReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { assert, describe, it } from "@effect/vitest";
import * as Crypto from "effect/Crypto";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as PubSub from "effect/PubSub";
import * as Queue from "effect/Queue";
Expand Down Expand Up @@ -134,6 +135,7 @@ interface HarnessOptions {
readonly settings?: ServerSettings;
readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"];
readonly pullRequestSummary?: PullRequestService["Service"]["summary"];
readonly existingWorktreePaths?: ReadonlyArray<string>;
readonly onDispatch?: (
command: AutoSettleCommand,
) => Effect.Effect<void, OrchestrationCommandInvariantError>;
Expand Down Expand Up @@ -235,6 +237,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
Layer.succeed(ServerSettingsService, serverSettings),
Layer.succeed(ServerActivation, Deferred.await(activation)),
Layer.succeed(Crypto.Crypto, testCrypto),
FileSystem.layerNoop({
exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false),
}),
);

return {
Expand Down Expand Up @@ -647,6 +652,43 @@ describe("ThreadSettlementReactor", () => {
),
);

it.effect("looks up the branch pull request from a thread's live worktree", () =>
Effect.scoped(
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse(NOW));
const fixture = yield* makeHarness({
snapshot: makeSnapshot(
[
makeThread("live-worktree", {
branch: "feature/live",
worktreePath: "/workspace/project-root/.worktrees/live",
}),
makeThread("deleted-worktree", {
branch: "feature/deleted",
worktreePath: "/workspace/project-root/.worktrees/deleted",
}),
],
[makeProject(PROJECT_ID, "/workspace/project-root")],
),
existingWorktreePaths: ["/workspace/project-root/.worktrees/live"],
});

yield* Effect.gen(function* () {
const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);

assert.deepStrictEqual(
new Set(yield* Ref.get(fixture.branchCalls)),
new Set([
{ cwd: "/workspace/project-root/.worktrees/live", branch: "feature/live" },
{ cwd: "/workspace/project-root", branch: "feature/deleted" },
]),
);
}).pipe(Effect.provide(fixture.layer));
}),
),
);

it.effect("carries the snapshot guard and survives a stale dispatch rejection", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
34 changes: 27 additions & 7 deletions apps/server/src/orchestration/ThreadSettlementReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Schedule from "effect/Schedule";
import type * as Scope from "effect/Scope";
Expand Down Expand Up @@ -37,6 +38,7 @@ export const make = Effect.gen(function* () {
const git = yield* GitManager.GitManager;
const pullRequests = yield* PullRequestService.PullRequestService;
const crypto = yield* Crypto.Crypto;
const fileSystem = yield* FileSystem.FileSystem;

const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* (
mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
Expand All @@ -54,6 +56,26 @@ export const make = Effect.gen(function* () {
mergedPullRequest.repository.toLowerCase() &&
thread.linkedPullRequest.number === mergedPullRequest.number)),
);
// Use the same cwd as the sidebar so both paths share GitManager's PR cache.
const lookupCwdByThreadId = new Map<string, string>();
yield* Effect.forEach(
candidates,
(thread) =>
Effect.gen(function* () {
const project = projects.get(thread.projectId);
if (project === undefined || thread.linkedPullRequest != null) return;
const worktreeExists =
thread.worktreePath !== null &&
(yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false)));
lookupCwdByThreadId.set(
thread.id,
worktreeExists && thread.worktreePath !== null
? thread.worktreePath
: project.workspaceRoot,
);
}),
{ concurrency: 8, discard: true },
);
const lookupKey = (thread: (typeof candidates)[number]) => {
if (thread.linkedPullRequest != null) {
return JSON.stringify([
Expand All @@ -64,11 +86,9 @@ export const make = Effect.gen(function* () {
]);
}
if (thread.branch === null) return JSON.stringify(["none", thread.id]);
const project = projects.get(thread.projectId);
const cwd = lookupCwdByThreadId.get(thread.id);
return JSON.stringify(
project === undefined
? ["missing-project", thread.id]
: ["branch", project.workspaceRoot, thread.branch],
cwd === undefined ? ["missing-project", thread.id] : ["branch", cwd, thread.branch],
);
};
const groups = Map.groupBy(candidates, lookupKey);
Expand Down Expand Up @@ -100,11 +120,11 @@ export const make = Effect.gen(function* () {
} satisfies SettlementPullRequest;
}
if (thread.branch === null) return null;
const project = projects.get(thread.projectId);
if (project === undefined) {
const cwd = lookupCwdByThreadId.get(thread.id);
if (cwd === undefined) {
return yield* Effect.die(new Error("thread project not found"));
}
return yield* git.branchPullRequest({ cwd: project.workspaceRoot, branch: thread.branch });
return yield* git.branchPullRequest({ cwd, branch: thread.branch });
});

yield* Effect.forEach(
Expand Down
46 changes: 35 additions & 11 deletions apps/web/src/browser/browserTargetResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe("browser target resolver", () => {
});
});

it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => {
it("preserves explicit loopback URL navigation for a remote Tailscale environment", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" });
const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
expect(
Expand All @@ -35,24 +35,40 @@ describe("browser target resolver", () => {
}),
).toEqual({
requestedUrl: "http://localhost:5173/dashboard?mode=test#results",
resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results",
resolutionKind: "direct-private-network",
resolvedUrl: "http://localhost:5173/dashboard?mode=test#results",
resolutionKind: "direct",
environmentId: "environment-1",
});
});

it("preserves URL credentials when mapping localhost onto a remote host", async () => {
it("preserves explicit IPv4 loopback URL navigation for a private network environment", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.50:3773" });
const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
expect(
resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
kind: "url",
url: "http://127.0.0.1:5999/",
}),
).toEqual({
requestedUrl: "http://127.0.0.1:5999/",
resolvedUrl: "http://127.0.0.1:5999/",
resolutionKind: "direct",
environmentId: "environment-1",
});
});

it("preserves URL credentials on explicit loopback navigation", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" });
const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
expect(
resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
kind: "url",
url: "http://user:p%40ss@localhost:5173/dashboard",
}).resolvedUrl,
).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard");
).toBe("http://user:p%40ss@localhost:5173/dashboard");
});

it("maps credentialed localhost URLs onto private IPv6 hosts", async () => {
it("preserves credentialed loopback URLs for private IPv6 environments", async () => {
readPreparedConnection.mockReturnValue({
httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773",
});
Expand All @@ -62,18 +78,18 @@ describe("browser target resolver", () => {
kind: "url",
url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results",
}).resolvedUrl,
).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results");
).toBe("http://user:p%40ss@localhost:5173/dashboard?mode=test#results");
});

it("maps schemeless localhost navigation onto a remote environment host", async () => {
it("preserves schemeless localhost navigation for a remote environment", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" });
const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
expect(
resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
kind: "url",
url: "localhost:3000/app",
}).resolvedUrl,
).toBe("http://192.168.1.25:3000/app");
).toBe("localhost:3000/app");
});

it("keeps localhost navigation local for a local environment", async () => {
Expand Down Expand Up @@ -117,12 +133,12 @@ describe("browser target resolver", () => {
port: 5173,
}),
).toThrow(/authenticated preview gateway/);
expect(() =>
expect(
resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
kind: "url",
url: "http://localhost:5173",
}),
).toThrow(/authenticated preview gateway/);
).toMatchObject({ resolvedUrl: "http://localhost:5173", resolutionKind: "direct" });
});

it("normalizes schemeless localhost server-picker values", async () => {
Expand All @@ -136,6 +152,14 @@ describe("browser target resolver", () => {
).toBe("http://localhost:3000/app");
});

it("maps discovered loopback servers onto a remote environment host", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" });
const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver");
expect(
resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:3000/app"),
).toBe("http://192.168.1.25:3000/app");
});

it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" });
const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver");
Expand Down
42 changes: 14 additions & 28 deletions apps/web/src/browser/browserTargetResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,30 +207,6 @@ export function resolveBrowserNavigationTarget(
target: BrowserNavigationTarget,
): PreviewUrlResolution {
if (target.kind === "url") {
let parsed: URL | null = null;
try {
parsed = new URL(normalizePreviewUrl(target.url));
} catch {
// Preserve the existing direct-navigation behavior so the preview host
// reports malformed URL errors through its normal navigation path.
}
if (parsed && isLoopbackHost(parsed.hostname)) {
const environmentUrl = readEnvironmentUrl(environmentId);
if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) {
return resolveEnvironmentPortTarget(
environmentId,
{
kind: "environment-port",
port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)),
protocol: parsed.protocol === "https:" ? "https" : "http",
path: `${parsed.pathname}${parsed.search}${parsed.hash}`,
},
environmentUrl,
target.url,
parsed,
);
}
}
return {
requestedUrl: target.url,
resolvedUrl: target.url,
Expand All @@ -244,10 +220,20 @@ export function resolveBrowserNavigationTarget(
export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string {
try {
const normalizedUrl = normalizePreviewUrl(rawUrl);
return resolveBrowserNavigationTarget(environmentId, {
kind: "url",
url: normalizedUrl,
}).resolvedUrl;
const parsed = new URL(normalizedUrl);
if (!isLoopbackHost(parsed.hostname)) return normalizedUrl;
return resolveEnvironmentPortTarget(
environmentId,
{
kind: "environment-port",
port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)),
protocol: parsed.protocol === "https:" ? "https" : "http",
path: `${parsed.pathname}${parsed.search}${parsed.hash}`,
},
readEnvironmentUrl(environmentId),
rawUrl,
parsed,
).resolvedUrl;
} catch {
return rawUrl;
}
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/components/ComposerCitationNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ export type ComposerCitationCommentTarget = {
export const ComposerCitationCommentContext = createContext<{
openComment: ComposerCitationCommentTarget | null;
onOpenChange: (nodeKey: NodeKey, open: boolean) => void;
}>({ openComment: null, onOpenChange: () => {} });
onSubmitAndSend: () => void;
}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} });

/** Consume a cite action once its controlled prompt has been committed to the editor. */
export function $consumeComposerCitationCommentRequest(requestRef: {
Expand Down Expand Up @@ -127,6 +128,11 @@ function ComposerCitationDecorator(props: { citation: AssistantCitation; nodeKey
commentContext.onOpenChange(props.nodeKey, open);
},
onSave: onSaveComment,
onSaveAndSend: (comment) => {
if (!onSaveComment(comment)) return false;
commentContext.onSubmitAndSend();
return true;
},
}}
onRemove={onRemove}
/>
Expand Down
Loading
Loading