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
22 changes: 20 additions & 2 deletions apps/web/src/components/ChatMarkdown.assets.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,25 @@ import { create, type ReactTestRenderer } from "react-test-renderer";
import { expect, it, vi } from "vite-plus/test";

const mint = vi.hoisted(() => vi.fn());
vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
// Atoms derived from the server config run in a real registry over a missing
// config; every other subscription reads nothing.
vi.mock("@effect/atom-react", async () => {
const { AtomRegistry } = await import("effect/reactivity");
const registry = AtomRegistry.make();
const derived = new Set(["markdown-server-config-fields", "pull-request-link-mode"]);
return {
useAtomValue: (atom: { readonly label?: readonly [string, ...unknown[]] }) =>
derived.has(atom.label?.[0] ?? "") ? registry.get(atom as never) : null,
};
});
vi.mock("../state/server", async () => {
const { Atom } = await import("effect/reactivity");
const noConfig = Atom.make(null);
return {
serverEnvironment: { configValueAtom: () => noConfig },
environmentServerConfigsAtom: Atom.make(new Map()),
};
});
vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => undefined }));
vi.mock("../hooks/useSettings", async (importOriginal) => {
Expand Down Expand Up @@ -36,7 +54,7 @@ vi.mock("../state/session", async (importOriginal) => ({
...(await importOriginal<typeof import("../state/session")>()),
readEnvironmentScope: () => false,
useEnvironmentScope: () => false,
usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }),
readPreparedConnection: () => ({ httpBaseUrl: "https://host.test" }),
}));
vi.mock("../state/entities", () => ({
readEnvironmentSupportsServerBrowser: () => false,
Expand Down
21 changes: 15 additions & 6 deletions apps/web/src/components/ChatMarkdown.permissions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ const state = vi.hoisted(() => ({
linkedPullRequest: null as ThreadLinkedPullRequest | null,
}));

vi.mock("@effect/atom-react", () => ({ useAtomValue: () => serverConfig }));
vi.mock("@effect/atom-react", async () => {
const { AtomRegistry } = await import("effect/reactivity");
const registry = AtomRegistry.make();
return { useAtomValue: (atom: never) => registry.get(atom) };
});
vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => undefined }));
vi.mock("../hooks/useSettings", () => ({
Expand Down Expand Up @@ -70,18 +74,23 @@ vi.mock("../state/session", async () => {
},
() => readEnvironmentScope(id, scope),
),
usePreparedConnection: () => ({ _tag: "None" }),
};
});
vi.mock("../state/server", () => ({
serverEnvironment: { configValueAtom: () => "config" },
}));
vi.mock("../state/server", async () => {
const { Atom } = await import("effect/reactivity");
const config = Atom.make(() => serverConfig);
return {
serverEnvironment: { configValueAtom: () => config },
environmentServerConfigsAtom: Atom.make(
() => new Map([[threadRef.environmentId, serverConfig]]),
),
};
});
vi.mock("../state/threads", () => ({
threadEnvironment: { updateMetadata: "updateMetadata" },
}));
vi.mock("../state/entities", () => ({
readEnvironmentSupportsServerBrowser: () => false,
useServerConfigs: () => new Map([[threadRef.environmentId, serverConfig]]),
readThreadShell: () => ({ linkedPullRequest: state.linkedPullRequest }),
useProjects: () => [{ id: linkedPullRequest.projectId, environmentId: threadRef.environmentId }],
}));
Expand Down
21 changes: 19 additions & 2 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,25 @@ import { Button } from "./ui/button";
import { setMarkdownTaskChecked } from "./files/filePreviewMode";

vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null }));
vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
// Atoms derived from the server config run in a real registry over a missing
// config; every other subscription reads nothing.
vi.mock("@effect/atom-react", async () => {
const { AtomRegistry } = await import("effect/reactivity");
const registry = AtomRegistry.make();
const derived = new Set(["markdown-server-config-fields", "pull-request-link-mode"]);
return {
useAtomValue: (atom: { readonly label?: readonly [string, ...unknown[]] }) =>
derived.has(atom.label?.[0] ?? "") ? registry.get(atom as never) : null,
};
});
vi.mock("../state/server", async () => {
const { Atom } = await import("effect/reactivity");
const noConfig = Atom.make(null);
return {
serverEnvironment: { configValueAtom: () => noConfig },
environmentServerConfigsAtom: Atom.make(new Map()),
};
});
vi.mock("./chat/MermaidDiagram", () => ({
// Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG.
MermaidDiagram: () => <svg aria-label="Diagram" />,
Expand Down Expand Up @@ -59,7 +77,6 @@ vi.mock("../state/session", async (importOriginal) => {
...actual,
useEnvironmentScope: hasScope,
readEnvironmentScope: hasScope,
usePreparedConnection: () => ({ _tag: "Loading" }),
};
});
vi.mock("../state/entities", () => ({
Expand Down
61 changes: 41 additions & 20 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/m
import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets";
import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/reactivity";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/reactivity";
import React, {
Children,
Suspense,
Expand Down Expand Up @@ -184,7 +185,11 @@ import { readThreadShell, useProjects } from "../state/entities";
import { serverEnvironment } from "../state/server";
import { shellEnvironment } from "../state/shell";
import { assetEnvironment } from "../state/assets";
import { readEnvironmentScope, usePreparedConnection, useEnvironmentScope } from "../state/session";
import {
readEnvironmentScope,
readPreparedConnection,
useEnvironmentScope,
} from "../state/session";
import { previewEnvironment } from "../state/preview";
import { useAtomCommand } from "../state/use-atom-command";
import { useAtomQueryRunner } from "../state/use-atom-query-runner";
Expand Down Expand Up @@ -2194,6 +2199,8 @@ function areMarkdownFileLinkPropsEqual(

const NO_MARKDOWN_EDITORS: ReadonlyArray<EditorId> = [];

type MarkdownServerConfigFields = ReturnType<typeof markdownServerConfigFields>;

/** The server config fields markdown reads. */
function markdownServerConfigFields(config: ServerConfig | null) {
return {
Expand All @@ -2208,6 +2215,29 @@ function markdownServerConfigFields(config: ServerConfig | null) {
};
}

function markdownServerConfigFieldsEqual(
previous: MarkdownServerConfigFields,
next: MarkdownServerConfigFields,
): boolean {
return (
previous.pullRequestsEnabled === next.pullRequestsEnabled &&
previous.revealInFileManagerLabel === next.revealInFileManagerLabel &&
previous.availableEditors.length === next.availableEditors.length &&
previous.availableEditors.every((editor, index) => editor === next.availableEditors[index])
);
}

// The config object changes with every provider status push and snapshot. The
// atom keeps its previous value while the fields markdown reads are equal, so
// those pushes don't re-render every visible reply.
const markdownServerConfigFieldsAtom = Atom.family((environmentId: EnvironmentId | null) =>
Atom.make((get) => {
const next = markdownServerConfigFields(get(serverEnvironment.configValueAtom(environmentId)));
const previous = Option.getOrNull(get.self<MarkdownServerConfigFields>());
return previous !== null && markdownServerConfigFieldsEqual(previous, next) ? previous : next;
}).pipe(Atom.withLabel("markdown-server-config-fields")),
);

function useChatMarkdownState({
text,
cwd,
Expand Down Expand Up @@ -2279,7 +2309,6 @@ function useChatMarkdownState({
const canUseShellActions =
canOperateHost &&
canUseMarkdownFileShellActions(environmentId, remoteOpen.state.mode, remoteOpen.isResolved);
const preparedConnection = usePreparedConnection(environmentId);
const openMarkdownMedia = (
source: string,
resolvedFilePath?: string,
Expand All @@ -2291,8 +2320,10 @@ function useChatMarkdownState({
resolvedFilePath,
cwd,
threadRef,
// Read at click time: a reconnect replaces the prepared connection, and
// subscribing would hand every rendered markdown element a new context.
httpBaseUrl:
preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : undefined,
environmentId === null ? undefined : readPreparedConnection(environmentId)?.httpBaseUrl,
createAssetUrl,
onOpenFile: threadRef
? (path) => useRightPanelStore.getState().openFile(threadRef, path)
Expand Down Expand Up @@ -2323,20 +2354,9 @@ function useChatMarkdownState({
},
);
};
// Only the parts of the server config markdown reads. The config object itself
// changes with every provider status push, which would re-render every
// visible reply.
const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
const nextConfigFields = markdownServerConfigFields(serverConfig);
const [configFields, setConfigFields] = useState(nextConfigFields);
if (
configFields.pullRequestsEnabled !== nextConfigFields.pullRequestsEnabled ||
configFields.availableEditors !== nextConfigFields.availableEditors ||
configFields.revealInFileManagerLabel !== nextConfigFields.revealInFileManagerLabel
) {
setConfigFields(nextConfigFields);
}
const { pullRequestsEnabled, availableEditors, revealInFileManagerLabel } = configFields;
const { pullRequestsEnabled, availableEditors, revealInFileManagerLabel } = useAtomValue(
markdownServerConfigFieldsAtom(environmentId),
);
const projects = useProjects();
const [preferredEditor] = usePreferredEditor(availableEditors);
const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor);
Expand Down Expand Up @@ -2480,7 +2500,8 @@ function useChatMarkdownState({
});
};
const openMarkdownFileInPreview = (path: string) => {
if (!threadRef || !canOperatePreview || preparedConnection._tag === "None") {
const preparedConnection = threadRef ? readPreparedConnection(threadRef.environmentId) : null;
if (!threadRef || !canOperatePreview || preparedConnection === null) {
return Promise.resolve(
AsyncResult.failure<void, BrowserPreviewUnavailableError>(
Cause.fail(
Expand All @@ -2495,7 +2516,7 @@ function useChatMarkdownState({
threadRef,
filePath: path,
workspaceRoot: cwd,
httpBaseUrl: preparedConnection.value.httpBaseUrl,
httpBaseUrl: preparedConnection.httpBaseUrl,
createAssetUrl,
openPreview,
});
Expand Down
26 changes: 20 additions & 6 deletions apps/web/src/hooks/usePullRequestLinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ import {
parseChangeRequestUrl,
type ChangeRequestLink,
} from "~/lib/openPullRequestLink";
import { useProjects, useServerConfigs } from "~/state/entities";
import { useAtomValue } from "@effect/atom-react";
import { Atom } from "effect/reactivity";
import { appAtomRegistry } from "~/rpc/atomRegistry";
import { useProjects } from "~/state/entities";
import { environmentServerConfigsAtom } from "~/state/server";
import { threadEnvironment } from "~/state/threads";
import { useAtomCommand } from "~/state/use-atom-command";

Expand All @@ -38,12 +42,21 @@ export function canLinkChangeRequest(
return findProjectForChangeRequest(projects, parsed) !== undefined;
}

// A string, so provider status pushes and snapshots that keep the mode don't
// re-render every link renderer.
const linkModeAtom = Atom.family((environmentId: EnvironmentId | null) =>
Atom.make((get) =>
threadPullRequestLinkMode(
environmentId === null
? undefined
: get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities,
),
).pipe(Atom.withLabel("pull-request-link-mode")),
);

export function usePullRequestLinking(environmentId: EnvironmentId | null | undefined) {
const configs = useServerConfigs();
const projects = useProjects();
const capabilities =
environmentId == null ? undefined : configs.get(environmentId)?.environment.capabilities;
const mode = threadPullRequestLinkMode(capabilities);
const mode = useAtomValue(linkModeAtom(environmentId ?? null));
const link = useAtomCommand(threadEnvironment.linkPullRequest, { reportFailure: false });
const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: false });
const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false });
Expand Down Expand Up @@ -79,7 +92,8 @@ export function usePullRequestLinking(environmentId: EnvironmentId | null | unde
throw new Error("The pull request is not available in this environment.");
const legacyProject = findProjectForChangeRequest(environmentProjects, parsed);
const mutation = planThreadPullRequestMutation({
capabilities,
capabilities: appAtomRegistry.get(environmentServerConfigsAtom).get(threadRef.environmentId)
?.environment.capabilities,
threadId: threadRef.threadId,
reference: { ...parsed, url },
legacyProjectId: legacyProject?.id ?? null,
Expand Down
67 changes: 44 additions & 23 deletions apps/web/src/remoteOpen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import {
} from "@t3tools/contracts";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import { Atom } from "effect/reactivity";
import { useAtomValue } from "@effect/atom-react";
import { useEffect, useState } from "react";

import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
import { isLoopbackHostname } from "~/environments/primary/target";
import { useLocalStorage } from "~/hooks/useLocalStorage";
import { useEnvironmentPresentation } from "~/state/presentation";
import { environmentPresentations } from "~/state/presentation";

export interface RemoteOpenHost {
readonly kind: "ssh-alias" | RemoteOpenTarget["kind"];
Expand Down Expand Up @@ -80,29 +82,48 @@ export function resolveRemoteOpenState(input: {
return REMOTE_UNAVAILABLE;
}

function remoteOpenStatesEqual(previous: RemoteOpenState, next: RemoteOpenState): boolean {
if (previous.mode !== next.mode) return false;
if (previous.mode !== "remote-links" || next.mode !== "remote-links") return true;
return previous.host.kind === next.host.kind && previous.host.host === next.host.host;
}

// The presentation changes with every server config push and reconnect. This
// keeps the previous resolution while it is equal, so subscribers re-render only
// when where files open actually changes.
const remoteOpenResolutionAtom = Atom.family((environmentId: EnvironmentId) =>
Atom.make((get): RemoteOpenResolution => {
const presentation = get(environmentPresentations.presentationAtom(environmentId));
const entry = presentation?.entry ?? null;
if (entry === null) {
return UNRESOLVED_REMOTE_OPEN;
}
const profile = Option.getOrNull(entry.profile);
const next: RemoteOpenResolution = {
state: resolveRemoteOpenState({
target: entry.target,
sshAlias:
profile !== null && profile._tag === "SshConnectionProfile" ? profile.target.alias : null,
remoteOpenTargets: presentation?.serverConfig?.remoteOpenTargets,
isDesktopRenderer: window.desktopBridge !== undefined,
}),
isResolved: true,
};
const previous = Option.getOrNull(get.self<RemoteOpenResolution>());
return previous !== null &&
previous.isResolved &&
remoteOpenStatesEqual(previous.state, next.state)
? previous
: next;
}).pipe(Atom.withLabel(`remote-open-resolution:${environmentId}`)),
);

const UNRESOLVED_REMOTE_OPEN_ATOM = Atom.make(UNRESOLVED_REMOTE_OPEN);

export function useRemoteOpenResolution(environmentId: EnvironmentId | null): RemoteOpenResolution {
const { presentation } = useEnvironmentPresentation(environmentId);
// The presentation changes with every server config push. Read only the pieces
// used here so the compiler keys the result on them and callers keep a stable
// result across provider updates.
const entry = presentation?.entry ?? null;
const remoteOpenTargets = presentation?.serverConfig?.remoteOpenTargets;

if (entry === null) {
return UNRESOLVED_REMOTE_OPEN;
}
const profile = Option.getOrNull(entry.profile);
const sshAlias =
profile !== null && profile._tag === "SshConnectionProfile" ? profile.target.alias : null;
return {
state: resolveRemoteOpenState({
target: entry.target,
sshAlias,
remoteOpenTargets,
isDesktopRenderer: window.desktopBridge !== undefined,
}),
isResolved: true,
};
return useAtomValue(
environmentId === null ? UNRESOLVED_REMOTE_OPEN_ATOM : remoteOpenResolutionAtom(environmentId),
);
}

export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState {
Expand Down
Loading