diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 4e5240ee..b227b3c3 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -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 ────────────────────────────────── @@ -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, ); @@ -214,27 +219,26 @@ function testOpenclawGateway( // ── Step 2: WebSocket connect ─────────────────────────────────── const connectStart = Date.now(); try { - ws = yield* Effect.tryPromise(() => - new Promise((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((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", @@ -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, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index acd4c07c..d8350da7 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,4 +1,4 @@ -import { ProjectId, ThreadId } from "@okcode/contracts"; +import { MessageId, ProjectId, ThreadId } from "@okcode/contracts"; import { describe, expect, it } from "vitest"; import { @@ -7,6 +7,7 @@ import { buildHiddenProviderInput, buildExpiredTerminalContextToastCopy, deriveComposerSendState, + findLatestRevertableUserMessageId, } from "./ChatView.logic"; describe("deriveComposerSendState", () => { @@ -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(); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 16f750e5..5d78f3ad 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -168,6 +168,30 @@ export function deriveComposerSendState(options: { }; } +export function findLatestRevertableUserMessageId( + timelineEntries: ReadonlyArray<{ + kind: string; + message?: { + id: MessageId; + role?: string; + }; + }>, + revertTurnCountByUserMessageId: ReadonlyMap, +): 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; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 14bda316..55585a81 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -119,6 +119,7 @@ import { LockOpenIcon, PaperclipIcon, PictureInPicture2Icon, + Undo2Icon, XIcon, } from "lucide-react"; import { Button } from "./ui/button"; @@ -215,6 +216,7 @@ import { cloneComposerAttachmentForRetry, collectUserMessageBlobPreviewUrls, deriveComposerSendState, + findLatestRevertableUserMessageId, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, PullRequestDialogState, @@ -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; @@ -5322,6 +5328,21 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { ref={composerFooterActionsRef} className="flex shrink-0 items-center gap-2" > + {latestRevertableUserMessageId ? ( + + ) : null} {pendingUserInputs.length === 0 && ( <> +
{label}
+
+
+ {buildInfo.version} + + {buildInfo.surface} + + + {buildInfo.platform}/{buildInfo.arch} + +
+
+ {buildInfo.channel} + + {buildInfo.commitHash ?? "unknown"} + + {buildInfo.buildTimestamp} +
+
+ + ); +} + function BackgroundImageSettings({ backgroundImageUrl, backgroundImageOpacity, @@ -375,8 +402,9 @@ function SettingsRouteView() { const [fontSizeOverride, setFontSizeOverrideState] = useState(() => getStoredFontSizeOverride(), ); - const [openclawTestResult, setOpenclawTestResult] = - useState(null); + const [openclawTestResult, setOpenclawTestResult] = useState( + null, + ); const [openclawTestLoading, setOpenclawTestLoading] = useState(false); const globalEnvironmentVariablesQuery = useQuery(globalEnvironmentVariablesQueryOptions()); @@ -2146,9 +2174,7 @@ function SettingsRouteView() { {openclawTestResult.success @@ -2176,9 +2202,7 @@ function SettingsRouteView() { )}
- - {step.name} - + {step.name} {step.durationMs}ms @@ -2223,9 +2247,7 @@ function SettingsRouteView() { {/* Error summary */} {openclawTestResult.error && - !openclawTestResult.steps.some( - (s) => s.status === "fail", - ) && ( + !openclawTestResult.steps.some((s) => s.status === "fail") && (
{openclawTestResult.error}
@@ -2266,30 +2288,10 @@ function SettingsRouteView() { title="Build" description="Current app-shell and server build metadata." control={ -
-
-
- App -
- - {`${APP_BUILD_INFO.version} · ${APP_BUILD_INFO.surface} · ${APP_BUILD_INFO.platform}/${APP_BUILD_INFO.arch}`} - - - {`${APP_BUILD_INFO.channel} · ${APP_BUILD_INFO.commitHash ?? "unknown"} · ${APP_BUILD_INFO.buildTimestamp}`} - -
+
+ {serverConfigQuery.data?.buildInfo ? ( -
-
- Server -
- - {`${serverConfigQuery.data.buildInfo.version} · ${serverConfigQuery.data.buildInfo.surface} · ${serverConfigQuery.data.buildInfo.platform}/${serverConfigQuery.data.buildInfo.arch}`} - - - {`${serverConfigQuery.data.buildInfo.channel} · ${serverConfigQuery.data.buildInfo.commitHash ?? "unknown"} · ${serverConfigQuery.data.buildInfo.buildTimestamp}`} - -
+ ) : null}
} diff --git a/bun.lock b/bun.lock index 0d36857e..f27bd2ec 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ }, "apps/desktop": { "name": "@okcode/desktop", - "version": "0.17.0", + "version": "0.18.0", "dependencies": { "effect": "catalog:", "electron": "40.6.0", @@ -103,7 +103,7 @@ }, "apps/mobile": { "name": "@okcode/mobile", - "version": "0.17.0", + "version": "0.18.0", "dependencies": { "@capacitor/android": "^8.3.0", "@capacitor/app": "^8.1.0", @@ -123,7 +123,7 @@ }, "apps/server": { "name": "okcodes", - "version": "0.17.0", + "version": "0.18.0", "bin": { "okcode": "./dist/index.mjs", }, @@ -154,7 +154,7 @@ }, "apps/web": { "name": "@okcode/web", - "version": "0.17.0", + "version": "0.18.0", "dependencies": { "@base-ui/react": "^1.2.0", "@codemirror/language": "^6.12.3", @@ -215,7 +215,7 @@ }, "packages/contracts": { "name": "@okcode/contracts", - "version": "0.17.0", + "version": "0.18.0", "dependencies": { "effect": "catalog:", }, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 355f9887..115998d3 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -432,9 +432,7 @@ export interface NativeApi { input: SaveProjectEnvironmentVariablesInput, ) => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; - testOpenclawGateway: ( - input: TestOpenclawGatewayInput, - ) => Promise; + testOpenclawGateway: (input: TestOpenclawGatewayInput) => Promise; }; orchestration: { getSnapshot: () => Promise;