From e01468213f820a327bcfde17ae2cae99e4307def Mon Sep 17 00:00:00 2001 From: Artem Date: Fri, 14 Aug 2026 14:49:28 +0300 Subject: [PATCH] fix(web): load pull request images from private repositories --- apps/server/src/assets/AssetAccess.test.ts | 41 +++++++++- apps/server/src/assets/AssetAccess.ts | 62 ++++++++++++++- apps/server/src/http.test.ts | 76 ++++++++++++++++++- apps/server/src/http.ts | 66 ++++++++++++++-- apps/server/src/ws.ts | 15 +++- apps/web/src/components/ChatMarkdown.tsx | 37 +++++++-- .../pullRequest/PullRequestMarkdown.tsx | 35 ++++++++- .../pullRequest/PullRequestMarkdownEditor.tsx | 5 +- .../PullRequestReviewAnnotation.tsx | 2 + .../pullRequest/PullRequestSummaryTab.tsx | 47 ++++++++++-- .../pullRequest/PullRequestTimelineTab.tsx | 29 ++++++- packages/contracts/src/assets.ts | 13 ++++ 12 files changed, 392 insertions(+), 36 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 0a1972c2827c..cb75020b3ef8 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,5 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; +import { + AssetGitHubAttachmentUrlValidationError, + AssetPreviewTypeValidationError, + ThreadId, +} from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; @@ -31,6 +35,31 @@ const testLayer = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { + it.effect("signs only GitHub user attachment URLs", () => + Effect.gen(function* () { + const url = "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/user-attachments/assets/dec6b30a-7724-4590-802a-af5586a2724d"; + const result = yield* issueAssetUrl({ + resource: { _tag: "github-user-attachment", url }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + expect( + yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)), + ).toEqual({ + kind: "github-user-attachment", + url, + }); + + const error = yield* issueAssetUrl({ + resource: { + _tag: "github-user-attachment", + url: "https://example.com/user-attachments/assets/dec6b30a-7724-4590-802a-af5586a2724d", + }, + }).pipe(Effect.flip); + expect(error).toBeInstanceOf(AssetGitHubAttachmentUrlValidationError); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues workspace URLs that resolve the entry file and sibling assets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -191,7 +220,9 @@ describe("AssetAccess", () => { const path = yield* Path.Path; const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001"; const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.png`); - yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.makeDirectory(config.attachmentsDir, { + recursive: true, + }); yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); const result = yield* issueAssetUrl({ @@ -299,7 +330,11 @@ describe("AssetAccess", () => { yield* fileSystem.writeFileString(path.join(root, "brand", "saved.svg"), "saved"); const result = yield* issueAssetUrl({ - resource: { _tag: "project-favicon", cwd: root, path: "brand/hint.svg" }, + resource: { + _tag: "project-favicon", + cwd: root, + path: "brand/hint.svg", + }, projectFaviconPath: "brand/saved.svg", }); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 7157513b14d3..d30ee3378ca8 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -2,6 +2,7 @@ import type { AssetResource } from "@t3tools/contracts"; import { AssetAttachmentNotFoundError, AssetPreviewTypeValidationError, + AssetGitHubAttachmentUrlValidationError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, AssetProjectFaviconResolutionError, @@ -88,6 +89,12 @@ const AssetClaimsSchema = Schema.Union([ relativePath: Schema.NullOr(Schema.String), expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("github-user-attachment"), + url: Schema.String, + expiresAt: Schema.Number, + }), ]); type AssetClaims = typeof AssetClaimsSchema.Type; @@ -95,7 +102,27 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); -export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; +export type ResolvedAsset = + | { readonly kind: "file"; readonly path: string } + | { readonly kind: "github-user-attachment"; readonly url: string }; + +export function isGitHubUserAttachmentUrl(value: string): boolean { + try { + const url = new URL(value); + return ( + url.protocol === "https:" && + url.hostname === "github.com" && + url.port === "" && + url.username === "" && + url.password === "" && + url.search === "" && + url.hash === "" && + /^\/user-attachments\/assets\/[0-9a-f-]+$/i.test(url.pathname) + ); + } catch { + return false; + } +} function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -302,11 +329,16 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; if (relativePath && !isWorkspaceImagePreviewPath(relativePath)) { - return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); + return yield* new AssetPreviewTypeValidationError({ + resource: input.resource, + }); } sourcePath = relativePath ?? undefined; const canonicalFaviconPath = relativePath - ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + ? yield* resolveCanonicalWorkspaceFile({ + workspaceRoot, + relativePath, + }).pipe( Effect.mapError( (cause) => new AssetProjectFaviconInspectionError({ @@ -363,6 +395,21 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } break; } + case "github-user-attachment": { + if (!isGitHubUserAttachmentUrl(input.resource.url)) { + return yield* new AssetGitHubAttachmentUrlValidationError({ + resource: input.resource, + }); + } + claims = { + version: 1, + kind: "github-user-attachment", + url: input.resource.url, + expiresAt, + }; + fileName = path.basename(new URL(input.resource.url).pathname); + break; + } } const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -409,6 +456,15 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const claims = decodeClaims(encodedPayload); if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; + if (claims.kind === "github-user-attachment") { + return isGitHubUserAttachmentUrl(claims.url) + ? ({ + kind: "github-user-attachment", + url: claims.url, + } satisfies ResolvedAsset) + : null; + } + if (claims.kind === "attachment") { const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index ec4d2aae16e6..62198ad611c6 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,7 +1,27 @@ import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { describe } from "vite-plus/test"; -import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; +import * as ProcessRunner from "./processRunner.ts"; +import { + assetResponseHeaders, + isLoopbackHostname, + proxyGitHubUserAttachment, + resolveDevRedirectUrl, +} from "./http.ts"; + +const processResult = (stdout: string): ProcessRunner.ProcessRunOutput => ({ + stdout, + stderr: "", + code: 0 as ProcessRunner.ProcessRunOutput["code"], + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, +}); describe("http dev routing", () => { it("treats localhost and loopback addresses as local", () => { @@ -36,6 +56,12 @@ describe("assetResponseHeaders", () => { expect(assetResponseHeaders("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/attachments/user-image.SVG")).toHaveProperty( "Content-Security-Policy", ); + expect( + assetResponseHeaders( + "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/user-attachments/assets/00000000-0000-0000-0000-000000000000", + "image/svg+xml", + ), + ).toHaveProperty("Content-Security-Policy"); }); it("does not apply document policy to raster images", () => { @@ -45,3 +71,51 @@ describe("assetResponseHeaders", () => { }); }); }); + +describe("GitHub attachment proxy", () => { + it.effect("returns 404 without a GitHub token", () => + proxyGitHubUserAttachment("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/user-attachments/assets/id").pipe( + Effect.provide( + Layer.merge( + Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => Effect.succeed(processResult("")), + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected fetch")), + ), + ), + ), + Effect.tap((response) => Effect.sync(() => expect(response.status).toBe(404))), + ), + ); + + it.effect("streams authenticated images with safe headers", () => { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("", { headers: { "content-type": "image/svg+xml" } }), + ), + ), + ); + return proxyGitHubUserAttachment("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/user-attachments/assets/id").pipe( + Effect.provide( + Layer.merge( + Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => Effect.succeed(processResult("token\n")), + }), + Layer.succeed(HttpClient.HttpClient, httpClient), + ), + ), + Effect.tap((response) => + Effect.sync(() => { + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toBe("image/svg+xml"); + expect(response.headers["content-security-policy"]).toContain("sandbox"); + expect(response.body._tag).toBe("Stream"); + }), + ), + ); + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 0da55686b92f..a5463dd2884b 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -16,6 +16,7 @@ import { cast } from "effect/Function"; import { HttpBody, HttpClient, + HttpClientRequest, HttpClientResponse, HttpMiddleware, HttpRouter, @@ -38,6 +39,7 @@ import { failEnvironmentInternal, } from "./auth/http.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import * as ProcessRunner from "./processRunner.ts"; import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; @@ -45,16 +47,61 @@ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; -export function assetResponseHeaders(filePath: string): Record { +export function assetResponseHeaders( + filePath: string, + contentType?: string, +): Record { return { "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff", - ...(filePath.toLowerCase().endsWith(".svg") + ...(contentType === "image/svg+xml" || filePath.toLowerCase().endsWith(".svg") ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } : {}), }; } +export const proxyGitHubUserAttachment = Effect.fn("proxyGitHubUserAttachment")(function* ( + url: string, +) { + const processRunner = yield* ProcessRunner.ProcessRunner; + const token = yield* processRunner + .run({ + command: "gh", + args: ["auth", "token", "--hostname", "github.com"], + timeout: "10 seconds", + maxOutputBytes: 4096, + }) + .pipe( + Effect.map((result) => (result.code === 0 ? result.stdout.trim() : "")), + Effect.orElseSucceed(() => ""), + ); + if (token.length === 0) return HttpServerResponse.text("Not Found", { status: 404 }); + + const httpClient = yield* HttpClient.HttpClient; + const response = yield* httpClient + .execute( + HttpClientRequest.get(url).pipe( + HttpClientRequest.setHeader("accept", "image/*"), + HttpClientRequest.bearerToken(token), + ), + ) + .pipe(Effect.orElseSucceed(() => null)); + if (!response || response.status < 200 || response.status >= 300) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const contentType = response.headers["content-type"]?.split(";", 1)[0] ?? ""; + if (!contentType.startsWith("image/")) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + return HttpServerResponse.stream(response.stream, { + status: 200, + headers: { + ...assetResponseHeaders(url, contentType), + "Content-Type": contentType, + }, + }); +}); + export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { global: true, }); @@ -217,6 +264,9 @@ export const assetRouteLayer = HttpRouter.add( if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); } + if (asset.kind === "github-user-attachment") { + return yield* proxyGitHubUserAttachment(asset.url).pipe(Effect.provide(ProcessRunner.layer)); + } return yield* HttpServerResponse.file(asset.path, { status: 200, headers: assetResponseHeaders(asset.path), @@ -270,7 +320,9 @@ export const staticAndDevRouteLayer = HttpRouter.add( hasPathTraversalSegment || staticRelativePath.includes("\0") ) { - return HttpServerResponse.text("Invalid static file path", { status: 400 }); + return HttpServerResponse.text("Invalid static file path", { + status: 400, + }); } const isWithinStaticRoot = (candidate: string) => @@ -279,14 +331,18 @@ export const staticAndDevRouteLayer = HttpRouter.add( let filePath = path.resolve(staticRoot, staticRelativePath); if (!isWithinStaticRoot(filePath)) { - return HttpServerResponse.text("Invalid static file path", { status: 400 }); + return HttpServerResponse.text("Invalid static file path", { + status: 400, + }); } const ext = path.extname(filePath); if (!ext) { filePath = path.resolve(filePath, "index.html"); if (!isWithinStaticRoot(filePath)) { - return HttpServerResponse.text("Invalid static file path", { status: 400 }); + return HttpServerResponse.text("Invalid static file path", { + status: 400, + }); } } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6436a5e441ac..5f45168b7d8c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1223,7 +1223,9 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }).pipe( + Queue.offer(liveBuffer, { + kind: "synchronized" as const, + }).pipe( Effect.andThen(Queue.takeAll(liveBuffer)), Effect.flatMap(coalesceShellLiveInputs), ), @@ -1373,7 +1375,9 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), + Queue.offer(liveBuffer, { + kind: "synchronized" as const, + }), ).pipe(Stream.drain), bufferedLiveStream, ) @@ -1415,7 +1419,9 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), + Queue.offer(liveBuffer, { + kind: "synchronized" as const, + }), ).pipe(Stream.drain), bufferedLiveStream, ) @@ -1870,6 +1876,9 @@ const makeWsRpcLayer = ( : {}), }); } + if (input.resource._tag === "github-user-attachment") { + return yield* issueAssetUrl({ resource: input.resource }); + } const thread = yield* projectionSnapshotQuery .getThreadShellById(input.resource.threadId) .pipe( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e9390ed0a8aa..ade7b34b20da 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -110,6 +110,7 @@ interface ChatMarkdownProps { className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; + imageComponent?: Components["img"]; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; @@ -185,7 +186,12 @@ const CHAT_MARKDOWN_REHYPE_PLUGINS = [ /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ const GITHUB_ALERT_PRESENTATIONS: Record< string, - { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } + { + label: string; + Icon: typeof InfoIcon; + borderClassName: string; + titleClassName: string; + } > = { note: { label: "Note", @@ -334,9 +340,11 @@ function extractCodeBlock( const onlyChild = childNodes[0]; if ( - !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>( - onlyChild, - ) + !isValidElement<{ + className?: string; + children?: ReactNode; + node?: { tagName?: string }; + }>(onlyChild) ) { return null; } @@ -1200,7 +1208,11 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }, (error) => { reportMarkdownActionFailure( - { operation: "copy-file-path", target: targetPath, copyTarget: title }, + { + operation: "copy-file-path", + target: targetPath, + copyTarget: title, + }, error, ); toastManager.add( @@ -1229,7 +1241,12 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ [ { id: "open", label: "Open in editor" }, ...(onOpenInBrowser - ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) + ? ([ + { + id: "open-in-browser", + label: "Open in integrated browser", + }, + ] as const) : []), { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, @@ -1327,6 +1344,7 @@ function ChatMarkdown({ skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, + imageComponent, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -1480,6 +1498,7 @@ function ChatMarkdown({ }; return { + ...(imageComponent ? { img: imageComponent } : {}), p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, @@ -1537,7 +1556,10 @@ function ChatMarkdown({ event.currentTarget.closest("li")?.dataset.taskMarkerOffset, ); if (!Number.isSafeInteger(markerOffset)) return; - onTaskListChange({ markerOffset, checked: event.currentTarget.checked }); + onTaskListChange({ + markerOffset, + checked: event.currentTarget.checked, + }); }} /> ); @@ -1683,6 +1705,7 @@ function ChatMarkdown({ diffThemeName, fileLinkParentSuffixByPath, inlineCodeFileLinkMetaByText, + imageComponent, isStreaming, markdownFileLinkMetaByHref, onTaskListChange, diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 46aa44dc1289..0d055bc79582 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,10 +1,28 @@ import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useMemo, type ComponentProps } from "react"; +import { useAssetUrl } from "~/assets/assetUrls"; import { cn } from "~/lib/utils"; import ChatMarkdown from "../ChatMarkdown"; import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; +const GITHUB_USER_ATTACHMENT_PREFIX = "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/user-attachments/assets/"; + +function PullRequestImage({ + environmentId, + src, + ...props +}: ComponentProps<"img"> & { environmentId: EnvironmentId }) { + const resource = useMemo( + () => ({ _tag: "github-user-attachment" as const, url: src ?? "" }), + [src], + ); + const authenticatedSrc = useAssetUrl(environmentId, resource); + return ; +} + /** * A pull request body, rendered with the app's markdown renderer plus a card for each upload * embedded in it, which that renderer drops on the floor. @@ -19,18 +37,33 @@ import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; export function PullRequestMarkdown({ text, cwd, + environmentId, className, }: { text: string; cwd: string; + environmentId: EnvironmentId; className?: string; }) { const segments = splitPullRequestBody(text); + const Image = useMemo( + () => + function PullRequestMarkdownImage({ src, ...props }: ComponentProps<"img">) { + return src?.startsWith(GITHUB_USER_ATTACHMENT_PREFIX) ? ( + + ) : ( + + ); + }, + [environmentId], + ); return (
{segments.map((segment) => { if (segment.kind === "markdown") { - return ; + return ( + + ); } const isVideo = segment.media === "video"; const Icon = isVideo ? PlayIcon : PaperclipIcon; diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx index f0145c059c0d..d5d2ee0a4757 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import type { EnvironmentId } from "@t3tools/contracts"; import { cn } from "~/lib/utils"; @@ -17,6 +18,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown"; export function PullRequestMarkdownEditor({ value, cwd, + environmentId, placeholder, label, saving, @@ -27,6 +29,7 @@ export function PullRequestMarkdownEditor({ }: { readonly value: string; readonly cwd: string; + readonly environmentId: EnvironmentId; readonly placeholder?: string | undefined; readonly label: string; readonly saving: boolean; @@ -81,7 +84,7 @@ export function PullRequestMarkdownEditor({ {empty ? (

Nothing to preview.

) : ( - + )}
) : ( diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index 47f59240ac5e..98b4f0eeca05 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -218,6 +218,7 @@ export function ReviewThreadCard({ className="mt-1" value={comment.body} cwd={workspaceRoot} + environmentId={environmentId} label="Edit comment" saving={savingEdit} onSave={(body) => void saveEdit(comment.id, body)} @@ -229,6 +230,7 @@ export function ReviewThreadCard({ className="min-w-0 flex-1 text-sm" text={comment.body} cwd={workspaceRoot} + environmentId={environmentId} /> {canEditComment(comment) ? (