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
53 changes: 28 additions & 25 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ import { TokenManager } from "./tokenManager.ts";
import { resolveRuntimeEnvironment, RuntimeEnv } from "./runtimeEnvironment.ts";
import { version as serverVersion } from "../package.json" with { type: "json" };
import { serverBuildInfo } from "./buildInfo";
import type { TestOpenclawGatewayInput, TestOpenclawGatewayResult, TestOpenclawGatewayStep } from "@okcode/contracts";
import type {
TestOpenclawGatewayInput,
TestOpenclawGatewayResult,
TestOpenclawGatewayStep,
} from "@okcode/contracts";
import NodeWebSocket from "ws";

// ── OpenClaw Gateway Connection Test ──────────────────────────────────
Expand Down Expand Up @@ -134,7 +138,8 @@ function testOpenclawGateway(
new Promise((resolve, reject) => {
const id = rpcId++;
const timeout = setTimeout(
() => reject(new Error(`RPC '${method}' timed out after ${OPENCLAW_TEST_RPC_TIMEOUT_MS}ms`)),
() =>
reject(new Error(`RPC '${method}' timed out after ${OPENCLAW_TEST_RPC_TIMEOUT_MS}ms`)),
OPENCLAW_TEST_RPC_TIMEOUT_MS,
);

Expand Down Expand Up @@ -214,27 +219,26 @@ function testOpenclawGateway(
// ── Step 2: WebSocket connect ───────────────────────────────────
const connectStart = Date.now();
try {
ws = yield* Effect.tryPromise(() =>
new Promise<NodeWebSocket>((resolve, reject) => {
const socket = new NodeWebSocket(gatewayUrl);
const timeout = setTimeout(() => {
socket.close();
reject(
new Error(
`Connection timed out after ${OPENCLAW_TEST_CONNECT_TIMEOUT_MS}ms`,
),
);
}, OPENCLAW_TEST_CONNECT_TIMEOUT_MS);

socket.on("open", () => {
clearTimeout(timeout);
resolve(socket);
});
socket.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
}),
ws = yield* Effect.tryPromise(
() =>
new Promise<NodeWebSocket>((resolve, reject) => {
const socket = new NodeWebSocket(gatewayUrl);
const timeout = setTimeout(() => {
socket.close();
reject(
new Error(`Connection timed out after ${OPENCLAW_TEST_CONNECT_TIMEOUT_MS}ms`),
);
}, OPENCLAW_TEST_CONNECT_TIMEOUT_MS);

socket.on("open", () => {
clearTimeout(timeout);
resolve(socket);
});
socket.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
}),
);
pushStep(
"WebSocket connect",
Expand All @@ -243,8 +247,7 @@ function testOpenclawGateway(
`Connected in ${Date.now() - connectStart}ms`,
);
} catch (err) {
const detail =
err instanceof Error ? err.message : "Connection failed.";
const detail = err instanceof Error ? err.message : "Connection failed.";
pushStep("WebSocket connect", "fail", Date.now() - connectStart, detail);
return {
success: false,
Expand Down
53 changes: 52 additions & 1 deletion apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ProjectId, ThreadId } from "@okcode/contracts";
import { MessageId, ProjectId, ThreadId } from "@okcode/contracts";
import { describe, expect, it } from "vitest";

import {
Expand All @@ -7,6 +7,7 @@ import {
buildHiddenProviderInput,
buildExpiredTerminalContextToastCopy,
deriveComposerSendState,
findLatestRevertableUserMessageId,
} from "./ChatView.logic";

describe("deriveComposerSendState", () => {
Expand Down Expand Up @@ -173,3 +174,53 @@ describe("buildLocalDraftThread", () => {
expect(thread.title).toBe("New thread");
});
});

describe("findLatestRevertableUserMessageId", () => {
it("returns the latest user message with a revertable turn count", () => {
const target = findLatestRevertableUserMessageId(
[
{
kind: "message",
message: {
id: MessageId.makeUnsafe("message-1"),
role: "user",
},
},
{
kind: "message",
message: {
id: MessageId.makeUnsafe("message-2"),
role: "assistant",
},
},
{
kind: "message",
message: {
id: MessageId.makeUnsafe("message-3"),
role: "user",
},
},
],
new Map([[MessageId.makeUnsafe("message-3"), 2]]),
);

expect(target).toBe(MessageId.makeUnsafe("message-3"));
});

it("returns null when no user message can be reverted", () => {
expect(
findLatestRevertableUserMessageId(
[
{
kind: "message",
message: {
id: MessageId.makeUnsafe("message-1"),
role: "assistant",
},
},
],
new Map(),
),
).toBeNull();
});
});
24 changes: 24 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,30 @@ export function deriveComposerSendState(options: {
};
}

export function findLatestRevertableUserMessageId(
timelineEntries: ReadonlyArray<{
kind: string;
message?: {
id: MessageId;
role?: string;
};
}>,
revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>,
): MessageId | null {
for (let index = timelineEntries.length - 1; index >= 0; index -= 1) {
const entry = timelineEntries[index];
if (!entry || entry.kind !== "message" || entry.message?.role !== "user") {
continue;
}

if (revertTurnCountByUserMessageId.has(entry.message.id)) {
return entry.message.id;
}
}

return null;
}

export function buildHiddenProviderInput(options: {
prompt: string;
terminalContexts: ReadonlyArray<TerminalContextDraft>;
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ import {
LockOpenIcon,
PaperclipIcon,
PictureInPicture2Icon,
Undo2Icon,
XIcon,
} from "lucide-react";
import { Button } from "./ui/button";
Expand Down Expand Up @@ -215,6 +216,7 @@ import {
cloneComposerAttachmentForRetry,
collectUserMessageBlobPreviewUrls,
deriveComposerSendState,
findLatestRevertableUserMessageId,
LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
LastInvokedScriptByProjectSchema,
PullRequestDialogState,
Expand Down Expand Up @@ -1219,6 +1221,10 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {

return byUserMessageId;
}, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
const latestRevertableUserMessageId = useMemo(
() => findLatestRevertableUserMessageId(timelineEntries, revertTurnCountByUserMessageId),
[revertTurnCountByUserMessageId, timelineEntries],
);

const completionSummary = useMemo(() => {
if (!latestTurnSettled) return null;
Expand Down Expand Up @@ -5322,6 +5328,21 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
ref={composerFooterActionsRef}
className="flex shrink-0 items-center gap-2"
>
{latestRevertableUserMessageId ? (
<Button
variant="outline"
size="sm"
type="button"
className="rounded-full px-3 text-muted-foreground/75 hover:text-foreground/85"
onClick={() => onRevertUserMessage(latestRevertableUserMessageId)}
disabled={isRevertingCheckpoint || isWorking}
title="Undo the latest revertable turn"
aria-label="Undo the latest revertable turn"
>
<Undo2Icon className="size-3.5" />
<span>Undo</span>
</Button>
) : null}
{pendingUserInputs.length === 0 && (
<>
<PromptEnhancer
Expand Down
70 changes: 36 additions & 34 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { type ReactNode, useCallback, useEffect, useState } from "react";
import type { TestOpenclawGatewayResult } from "@okcode/contracts";
import {
type BuildMetadata,
type ProjectId,
type ProviderKind,
DEFAULT_GIT_TEXT_GENERATION_MODEL,
Expand Down Expand Up @@ -249,6 +250,32 @@ function getErrorMessage(error: unknown): string {
return "Unknown error";
}

function BuildInfoBlock({ label, buildInfo }: { label: string; buildInfo: BuildMetadata }) {
return (
<dl className="min-w-0">
<dt className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</dt>
<dd className="mt-1 space-y-1 text-[11px] leading-5 text-muted-foreground">
<div className="flex min-w-0 flex-wrap gap-x-1.5 gap-y-0.5 font-mono">
<span className="font-medium text-foreground">{buildInfo.version}</span>
<span aria-hidden="true">·</span>
<span>{buildInfo.surface}</span>
<span aria-hidden="true">·</span>
<span>
{buildInfo.platform}/{buildInfo.arch}
</span>
</div>
<div className="flex min-w-0 flex-wrap gap-x-1.5 gap-y-0.5 font-mono">
<span>{buildInfo.channel}</span>
<span aria-hidden="true">·</span>
<span className="break-words">{buildInfo.commitHash ?? "unknown"}</span>
<span aria-hidden="true">·</span>
<span className="break-words">{buildInfo.buildTimestamp}</span>
</div>
</dd>
</dl>
);
}

function BackgroundImageSettings({
backgroundImageUrl,
backgroundImageOpacity,
Expand Down Expand Up @@ -375,8 +402,9 @@ function SettingsRouteView() {
const [fontSizeOverride, setFontSizeOverrideState] = useState<number | null>(() =>
getStoredFontSizeOverride(),
);
const [openclawTestResult, setOpenclawTestResult] =
useState<TestOpenclawGatewayResult | null>(null);
const [openclawTestResult, setOpenclawTestResult] = useState<TestOpenclawGatewayResult | null>(
null,
);
const [openclawTestLoading, setOpenclawTestLoading] = useState(false);

const globalEnvironmentVariablesQuery = useQuery(globalEnvironmentVariablesQueryOptions());
Expand Down Expand Up @@ -2146,9 +2174,7 @@ function SettingsRouteView() {
<span
className={cn(
"text-xs font-semibold",
openclawTestResult.success
? "text-emerald-500"
: "text-red-500",
openclawTestResult.success ? "text-emerald-500" : "text-red-500",
)}
>
{openclawTestResult.success
Expand Down Expand Up @@ -2176,9 +2202,7 @@ function SettingsRouteView() {
)}
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="font-medium text-foreground">
{step.name}
</span>
<span className="font-medium text-foreground">{step.name}</span>
<span className="tabular-nums text-muted-foreground text-[10px]">
{step.durationMs}ms
</span>
Expand Down Expand Up @@ -2223,9 +2247,7 @@ function SettingsRouteView() {

{/* Error summary */}
{openclawTestResult.error &&
!openclawTestResult.steps.some(
(s) => s.status === "fail",
) && (
!openclawTestResult.steps.some((s) => s.status === "fail") && (
<div className="mt-2 text-xs text-red-500">
{openclawTestResult.error}
</div>
Expand Down Expand Up @@ -2266,30 +2288,10 @@ function SettingsRouteView() {
title="Build"
description="Current app-shell and server build metadata."
control={
<div className="space-y-2 text-left">
<div className="space-y-0.5">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
App
</div>
<code className="block text-xs font-medium text-muted-foreground">
{`${APP_BUILD_INFO.version} · ${APP_BUILD_INFO.surface} · ${APP_BUILD_INFO.platform}/${APP_BUILD_INFO.arch}`}
</code>
<code className="block text-[11px] text-muted-foreground">
{`${APP_BUILD_INFO.channel} · ${APP_BUILD_INFO.commitHash ?? "unknown"} · ${APP_BUILD_INFO.buildTimestamp}`}
</code>
</div>
<div className="grid w-full gap-3 text-left sm:max-w-xl sm:grid-cols-2 sm:gap-5">
<BuildInfoBlock label="App" buildInfo={APP_BUILD_INFO} />
{serverConfigQuery.data?.buildInfo ? (
<div className="space-y-0.5">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Server
</div>
<code className="block text-xs font-medium text-muted-foreground">
{`${serverConfigQuery.data.buildInfo.version} · ${serverConfigQuery.data.buildInfo.surface} · ${serverConfigQuery.data.buildInfo.platform}/${serverConfigQuery.data.buildInfo.arch}`}
</code>
<code className="block text-[11px] text-muted-foreground">
{`${serverConfigQuery.data.buildInfo.channel} · ${serverConfigQuery.data.buildInfo.commitHash ?? "unknown"} · ${serverConfigQuery.data.buildInfo.buildTimestamp}`}
</code>
</div>
<BuildInfoBlock label="Server" buildInfo={serverConfigQuery.data.buildInfo} />
) : null}
</div>
}
Expand Down
10 changes: 5 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading