Unified · Split
| @@ -2602,6 +2602,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 2601 unmodified lines | ||
| 2602 | 2602 | cwd: "/tmp/repo", |
| 2603 | 2603 | }, |
| 2604 | 2604 | layers: { |
| 2605 | + vcsDriver: { | |
| 2606 | + isInsideWorkTree: () => Effect.succeed(true), | |
| 2607 | + }, | |
| 2605 | 2608 | gitManager: { |
| 2606 | 2609 | invalidateLocalStatus: () => Effect.void, |
| 2607 | 2610 | invalidateRemoteStatus: () => Effect.void, |
| @@ -2738,6 +2741,21 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 130 unmodified lines | ||
| 2738 | 2741 | createRef: (input) => Effect.succeed({ refName: input.refName }), |
| 2739 | 2742 | switchRef: (input) => Effect.succeed({ refName: input.refName }), |
| 2740 | 2743 | }, |
| 2744 | + vcsStatusBroadcaster: { | |
| 2745 | + refreshStatus: () => | |
| 2746 | + Effect.succeed({ | |
| 2747 | + isRepo: true, | |
| 2748 | + hasPrimaryRemote: true, | |
| 2749 | + isDefaultRef: true, | |
| 2750 | + refName: "main", | |
| 2751 | + hasWorkingTreeChanges: false, | |
| 2752 | + workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 2753 | + hasUpstream: true, | |
| 2754 | + aheadCount: 0, | |
| 2755 | + behindCount: 0, | |
| 2756 | + pr: null, | |
| 2757 | + }), | |
| 2758 | + }, | |
| 2741 | 2759 | reviewService: { |
| 2742 | 2760 | getDiffPreview: (input) => |
| 2743 | 2761 | Effect.succeed({ |
| @@ -16,9 +16,15 @@ import { · 15 unmodified lines | ||
| 16 | 16 | type ReactNode, |
| 17 | 17 | } from "react"; |
| 18 | 18 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; |
| 19 | +import { FileDiff } from "@pierre/diffs/react"; | |
| 19 | 20 | import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; |
| 20 | 21 | import { type TurnDiffSummary } from "../../types"; |
| 21 | 22 | import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; |
| 23 | +import { | |
| 24 | + getRenderablePatch, | |
| 25 | + resolveDiffThemeName, | |
| 26 | + resolveFileDiffPath, | |
| 27 | +} from "../../lib/diffRendering"; | |
| 22 | 28 | import ChatMarkdown from "../ChatMarkdown"; |
| 23 | 29 | import { |
| 24 | 30 | BotIcon, |
| @@ -67,6 +73,11 @@ import { · 42 unmodified lines | ||
| 67 | 73 | } from "./userMessageTerminalContexts"; |
| 68 | 74 | import { SkillInlineText } from "./SkillInlineText"; |
| 69 | 75 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; |
| 76 | +import { | |
| 77 | + buildReviewCommentRenderablePatch, | |
| 78 | + parseReviewCommentMessageSegments, | |
| 79 | + type ReviewCommentContext, | |
| 80 | +} from "../../reviewCommentContext"; | |
| 70 | 81 | |
| 71 | 82 | // --------------------------------------------------------------------------- |
| 72 | 83 | // Context — shared state consumed by every row component via Context. |
| @@ -837,6 +848,25 @@ const UserMessageBody = memo(function UserMessageBody(props: { · 764 unmodified lines | ||
| 837 | 848 | terminalContexts: ParsedTerminalContextEntry[]; |
| 838 | 849 | skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; |
| 839 | 850 | }) { |
| 851 | + const reviewCommentSegments = parseReviewCommentMessageSegments(props.text); | |
| 852 | + if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) { | |
| 853 | + return ( | |
| 854 | + <div className="space-y-3 text-sm leading-relaxed text-foreground"> | |
| 855 | + {reviewCommentSegments.map((segment) => | |
| 856 | + segment.kind === "text" ? ( | |
| 857 | + segment.text.trim().length > 0 ? ( | |
| 858 | + <div key={segment.id} className="whitespace-pre-wrap wrap-break-word"> | |
| 859 | + <SkillInlineText text={segment.text.trim()} skills={props.skills} /> | |
| 860 | + </div> | |
| 861 | + ) : null | |
| 862 | + ) : ( | |
| 863 | + <UserMessageReviewCommentCard key={segment.comment.id} comment={segment.comment} /> | |
| 864 | + ), | |
| 865 | + )} | |
| 866 | + </div> | |
| 867 | + ); | |
| 868 | + } | |
| 869 | + | |
| 840 | 870 | if (props.terminalContexts.length > 0) { |
| 841 | 871 | const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( |
| 842 | 872 | props.text, |
| @@ -930,6 +960,49 @@ const UserMessageBody = memo(function UserMessageBody(props: { · 87 unmodified lines | ||
| 930 | 960 | ); |
| 931 | 961 | }); |
| 932 | 962 | |
| 963 | +function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentContext }) { | |
| 964 | + const ctx = use(TimelineRowCtx); | |
| 965 | + const renderablePatch = getRenderablePatch( | |
| 966 | + buildReviewCommentRenderablePatch(comment), | |
| 967 | + `review-comment:${comment.id}`, | |
| 968 | + ); | |
| 969 | + | |
| 970 | + return ( | |
| 971 | + <div className="space-y-2 rounded-lg border border-border/70 bg-background/70 p-3"> | |
| 972 | + <div className="space-y-1"> | |
| 973 | + <div className="text-xs font-medium text-foreground"> | |
| 974 | + {formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)} | |
| 975 | + </div> | |
| 976 | + <div className="text-[11px] text-muted-foreground"> | |
| 977 | + {comment.sectionTitle} · {comment.rangeLabel} | |
| 978 | + </div> | |
| 979 | + </div> | |
| 980 | + {comment.text.length > 0 && ( | |
| 981 | + <div className="whitespace-pre-wrap wrap-break-word text-sm"> | |
| 982 | + <SkillInlineText text={comment.text} skills={ctx.skills} /> | |
| 983 | + </div> | |
| 984 | + )} | |
| 985 | + {renderablePatch?.kind === "files" && | |
| 986 | + renderablePatch.files.map((fileDiff) => ( | |
| 987 | + <FileDiff | |
| 988 | + key={resolveFileDiffPath(fileDiff)} | |
| 989 | + fileDiff={fileDiff} | |
| 990 | + options={{ | |
| 991 | + collapsed: false, | |
| 992 | + diffStyle: "unified", | |
| 993 | + theme: resolveDiffThemeName(ctx.resolvedTheme), | |
| 994 | + }} | |
| 995 | + /> | |
| 996 | + ))} | |
| 997 | + {renderablePatch?.kind === "raw" && ( | |
| 998 | + <pre className="overflow-x-auto rounded-md bg-muted/40 p-2 text-xs"> | |
| 999 | + {renderablePatch.text} | |
| 1000 | + </pre> | |
| 1001 | + )} | |
| 1002 | + </div> | |
| 1003 | + ); | |
| 1004 | +} | |
| 1005 | + | |
| 933 | 1006 | // --------------------------------------------------------------------------- |
| 934 | 1007 | // Structural sharing — reuse old row references when data hasn't changed |
| 935 | 1008 | // so LegendList (and React) can skip re-rendering unchanged items. |
| @@ -239,7 +239,28 @@ describe("remote environment api", () => { · 238 unmodified lines | ||
| 239 | 239 | httpBaseUrl: "https://remote.example.com/", |
| 240 | 240 | bearerToken: "bearer-token", |
| 241 | 241 | }), |
| 242 | − ).resolves.toBe("wss://remote.example.com/ws?wsToken=ws-token"); | |
| 242 | + ).resolves.toBe("wss://remote.example.com/?wsToken=ws-token"); | |
| 243 | + }); | |
| 244 | + | |
| 245 | + it("preserves the caller-provided websocket path when minting a token", async () => { | |
| 246 | + const fetchMock = vi.fn().mockResolvedValue( | |
| 247 | + new Response( | |
| 248 | + JSON.stringify({ | |
| 249 | + token: "ws-token", | |
| 250 | + expiresAt: "2026-05-01T12:05:00.000Z", | |
| 251 | + }), | |
| 252 | + { status: 200 }, | |
| 253 | + ), | |
| 254 | + ); | |
| 255 | + globalThis.fetch = fetchMock as typeof fetch; | |
| 256 | + | |
| 257 | + await expect( | |
| 258 | + resolveRemoteWebSocketConnectionUrl({ | |
| 259 | + wsBaseUrl: "wss://remote.example.com/custom/socket?existing=1", | |
| 260 | + httpBaseUrl: "https://remote.example.com/", | |
| 261 | + bearerToken: "bearer-token", | |
| 262 | + }), | |
| 263 | + ).resolves.toBe("wss://remote.example.com/custom/socket?existing=1&wsToken=ws-token"); | |
| 243 | 264 | }); |
| 244 | 265 | }); |
| 245 | 266 | |
| @@ -72,6 +72,7 @@ function createTestClient() { · 71 unmodified lines | ||
| 72 | 72 | clear: vi.fn(async () => undefined), |
| 73 | 73 | restart: vi.fn(async () => undefined), |
| 74 | 74 | close: vi.fn(async () => undefined), |
| 75 | + onEvent: vi.fn(() => () => undefined), | |
| 75 | 76 | onMetadata: vi.fn(() => () => undefined), |
| 76 | 77 | }, |
| 77 | 78 | projects: { |
| @@ -269,6 +269,17 @@ describe("retainThreadDetailSubscription", () => { · 268 unmodified lines | ||
| 269 | 269 | mockCreateEnvironmentConnection.mockImplementation((input) => { |
| 270 | 270 | const reconnect = vi.fn(async () => undefined); |
| 271 | 271 | mockConnectionReconnects.push(reconnect); |
| 272 | + queueMicrotask(() => { | |
| 273 | + input.onConfigSnapshot?.({ | |
| 274 | + environment: { | |
| 275 | + environmentId: input.knownEnvironment.environmentId, | |
| 276 | + label: input.knownEnvironment.label, | |
| 277 | + platform: { os: "darwin", arch: "arm64" }, | |
| 278 | + serverVersion: "0.0.0-test", | |
| 279 | + capabilities: { repositoryIdentity: true }, | |
| 280 | + }, | |
| 281 | + }); | |
| 282 | + }); | |
| 272 | 283 | return { |
| 273 | 284 | kind: input.kind, |
| 274 | 285 | environmentId: input.knownEnvironment.environmentId, |
| @@ -438,13 +449,13 @@ describe("retainThreadDetailSubscription", () => { · 163 unmodified lines | ||
| 438 | 449 | const stop = startEnvironmentConnectionService(new QueryClient()); |
| 439 | 450 | savedEnvironmentRegistryListener?.(); |
| 440 | 451 | await vi.waitFor(() => { |
| 441 | − expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes(2); | |
| 442 | 452 | expect( |
| 443 | 453 | listEnvironmentConnections().some( |
| 444 | 454 | (connection) => connection.environmentId === environmentId, |
| 445 | 455 | ), |
| 446 | 456 | ).toBe(true); |
| 447 | 457 | }); |
| 458 | + const createConnectionCallsBeforeReconnect = mockCreateEnvironmentConnection.mock.calls.length; | |
| 448 | 459 | |
| 449 | 460 | const release = retainThreadDetailSubscription(environmentId, threadId); |
| 450 | 461 | expect(mockSubscribeThread).toHaveBeenCalledTimes(1); |
| @@ -459,7 +470,9 @@ describe("retainThreadDetailSubscription", () => { · 8 unmodified lines | ||
| 459 | 470 | await vi.advanceTimersByTimeAsync(200); |
| 460 | 471 | await reconnectPromise; |
| 461 | 472 | await vi.waitFor(() => { |
| 462 | − expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes(3); | |
| 473 | + expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes( | |
| 474 | + createConnectionCallsBeforeReconnect + 1, | |
| 475 | + ); | |
| 463 | 476 | expect(mockSubscribeThread).toHaveBeenCalledTimes(2); |
| 464 | 477 | }); |
| 465 | 478 | |
| @@ -486,6 +499,33 @@ describe("retainThreadDetailSubscription", () => { · 20 unmodified lines | ||
| 486 | 499 | |
| 487 | 500 | const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } = |
| 488 | 501 | await import("./service"); |
| 502 | + mockCreateEnvironmentConnection.mockImplementation((input) => { | |
| 503 | + const reconnect = vi.fn(async () => undefined); | |
| 504 | + mockConnectionReconnects.push(reconnect); | |
| 505 | + queueMicrotask(() => { | |
| 506 | + input.onConfigSnapshot?.({ | |
| 507 | + environment: { | |
| 508 | + environmentId: input.knownEnvironment.environmentId, | |
| 509 | + label: input.knownEnvironment.label, | |
| 510 | + platform: { os: "darwin", arch: "arm64" }, | |
| 511 | + serverVersion: "0.0.0-test", | |
| 512 | + capabilities: { repositoryIdentity: true }, | |
| 513 | + }, | |
| 514 | + }); | |
| 515 | + }); | |
| 516 | + return { | |
| 517 | + kind: input.kind, | |
| 518 | + environmentId: input.knownEnvironment.environmentId, | |
| 519 | + knownEnvironment: input.knownEnvironment, | |
| 520 | + client: { | |
| 521 | + ...input.client, | |
| 522 | + isHeartbeatFresh: vi.fn(() => true), | |
| 523 | + }, | |
| 524 | + ensureBootstrapped: vi.fn(async () => undefined), | |
| 525 | + reconnect, | |
| 526 | + dispose: vi.fn(async () => undefined), | |
| 527 | + }; | |
| 528 | + }); | |
| 489 | 529 | |
| 490 | 530 | const stop = startEnvironmentConnectionService(new QueryClient()); |
| 491 | 531 | expect(mockConnectionReconnects).toHaveLength(1); |
| @@ -242,15 +242,10 @@ describe("WsTransport (web instrumentation)", () => { · 241 unmodified lines | ||
| 242 | 242 | socket.close(1012, "service restart"); |
| 243 | 243 | |
| 244 | 244 | await waitFor(() => { |
| 245 | − expect(onClose).toHaveBeenCalledWith( | |
| 246 | − { | |
| 247 | − code: 1012, | |
| 248 | − reason: "service restart", | |
| 249 | − }, | |
| 250 | − { | |
| 251 | − intentional: false, | |
| 252 | − }, | |
| 253 | − ); | |
| 245 | + expect(onClose).toHaveBeenCalledWith({ | |
| 246 | + code: 1012, | |
| 247 | + reason: "service restart", | |
| 248 | + }); | |
| 254 | 249 | expect(getWsConnectionStatus()).toMatchObject({ |
| 255 | 250 | attemptCount: 2, |
| 256 | 251 | closeReason: "service restart", |
| @@ -645,8 +645,8 @@ describe("WsTransport", () => { · 644 unmodified lines | ||
| 645 | 645 | }); |
| 646 | 646 | |
| 647 | 647 | it("does not retry stream subscriptions after application-level failures", async () => { |
| 648 | − const transport = createTransport("ws://localhost:3020"); | |
| 649 | − const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); | |
| 648 | + const warnSpy = vi.fn(); | |
| 649 | + const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); | |
| 650 | 650 | let attempts = 0; |
| 651 | 651 | |
| 652 | 652 | const unsubscribe = transport.subscribe( |
| @@ -684,8 +684,8 @@ describe("WsTransport", () => { · 31 unmodified lines | ||
| 684 | 684 | }); |
| 685 | 685 | |
| 686 | 686 | it("keeps retrying stream subscriptions after transport failures", async () => { |
| 687 | − const transport = createTransport("ws://localhost:3020"); | |
| 688 | − const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); | |
| 687 | + const warnSpy = vi.fn(); | |
| 688 | + const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); | |
| 689 | 689 | let attempts = 0; |
| 690 | 690 | |
| 691 | 691 | const unsubscribe = transport.subscribe( |
| @@ -717,8 +717,8 @@ describe("WsTransport", () => { · 25 unmodified lines | ||
| 717 | 717 | }); |
| 718 | 718 | |
| 719 | 719 | it("logs a transport disconnect once even when multiple subscriptions fail together", async () => { |
| 720 | − const transport = createTransport("ws://localhost:3020"); | |
| 721 | − const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); | |
| 720 | + const warnSpy = vi.fn(); | |
| 721 | + const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); | |
| 722 | 722 | |
| 723 | 723 | const unsubscribeA = transport.subscribe( |
| 724 | 724 | () => Stream.fail(new Error("SocketCloseError: 1006")), |
| @@ -31,6 +31,7 @@ export interface WsTransportOptions { · 30 unmodified lines | ||
| 31 | 31 | url: WsRpcProtocolSocketUrlProvider, |
| 32 | 32 | lifecycleHandlers?: WsProtocolLifecycleHandlers, |
| 33 | 33 | ) => Layer.Layer<RpcClient.Protocol, never, never>; |
| 34 | + readonly logWarning?: (message: string, metadata: { readonly error: string }) => void; | |
| 34 | 35 | /** |
| 35 | 36 | * Invoked at the start of {@link WsTransport.reconnect} before the session is replaced. |
| 36 | 37 | */ |
| @@ -172,18 +173,14 @@ export class WsTransport { · 135 unmodified lines | ||
| 172 | 173 | |
| 173 | 174 | const formattedError = formatErrorMessage(error); |
| 174 | 175 | if (!isTransportConnectionErrorMessage(formattedError)) { |
| 175 | − Effect.runSync( | |
| 176 | − Effect.logWarning("WebSocket RPC subscription failed", { error: formattedError }), | |
| 177 | − ); | |
| 176 | + this.logWarning("WebSocket RPC subscription failed", { error: formattedError }); | |
| 178 | 177 | return; |
| 179 | 178 | } |
| 180 | 179 | |
| 181 | 180 | if (!this.hasReportedTransportDisconnect) { |
| 182 | − Effect.runSync( | |
| 183 | − Effect.logWarning("WebSocket RPC subscription disconnected", { | |
| 184 | − error: formattedError, | |
| 185 | − }), | |
| 186 | − ); | |
| 181 | + this.logWarning("WebSocket RPC subscription disconnected", { | |
| 182 | + error: formattedError, | |
| 183 | + }); | |
| 187 | 184 | } |
| 188 | 185 | this.hasReportedTransportDisconnect = true; |
| 189 | 186 | await sleep(retryDelayMs); |
| @@ -252,6 +249,15 @@ export class WsTransport { · 62 unmodified lines | ||
| 252 | 249 | }; |
| 253 | 250 | } |
| 254 | 251 | |
| 252 | + private logWarning(message: string, metadata: { readonly error: string }) { | |
| 253 | + const logWarning = this.options?.logWarning; | |
| 254 | + if (logWarning) { | |
| 255 | + logWarning(message, metadata); | |
| 256 | + } else { | |
| 257 | + Effect.runSync(Effect.logWarning(message, metadata)); | |
| 258 | + } | |
| 259 | + } | |
| 260 | + | |
| 255 | 261 | private runStreamOnSession<TValue>( |
| 256 | 262 | session: TransportSession, |
| 257 | 263 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, |
| @@ -44,6 +44,7 @@ describe("TerminalOpenInput", () => { · 43 unmodified lines | ||
| 44 | 44 | expect( |
| 45 | 45 | decodes(TerminalOpenInput, { |
| 46 | 46 | threadId: "thread-1", |
| 47 | + terminalId: DEFAULT_TERMINAL_ID, | |
| 47 | 48 | cwd: "/tmp/project", |
| 48 | 49 | cols: 423, |
| 49 | 50 | rows: 40, |