Skip to content

Commit 76b2d28

Browse files
authored
Add undo for the latest revertable user turn (#362)
* Add undo action for latest revertable user turn - Find the newest user message that can be reverted - Show an Undo button in the chat composer footer - Add tests for the revertable message lookup * Restructure build metadata in settings and bump version (#360) - Split app and server build info into reusable blocks - Update package versions to 0.18.0 * Format chat settings and gateway IPC types - Reflow ws server and settings UI formatting - Keep `testOpenclawGateway` contract declaration tidy
1 parent 747c546 commit 76b2d28

6 files changed

Lines changed: 132 additions & 40 deletions

File tree

apps/server/src/wsServer.ts

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,11 @@ import { TokenManager } from "./tokenManager.ts";
9898
import { resolveRuntimeEnvironment, RuntimeEnv } from "./runtimeEnvironment.ts";
9999
import { version as serverVersion } from "../package.json" with { type: "json" };
100100
import { serverBuildInfo } from "./buildInfo";
101-
import type { TestOpenclawGatewayInput, TestOpenclawGatewayResult, TestOpenclawGatewayStep } from "@okcode/contracts";
101+
import type {
102+
TestOpenclawGatewayInput,
103+
TestOpenclawGatewayResult,
104+
TestOpenclawGatewayStep,
105+
} from "@okcode/contracts";
102106
import NodeWebSocket from "ws";
103107

104108
// ── OpenClaw Gateway Connection Test ──────────────────────────────────
@@ -134,7 +138,8 @@ function testOpenclawGateway(
134138
new Promise((resolve, reject) => {
135139
const id = rpcId++;
136140
const timeout = setTimeout(
137-
() => reject(new Error(`RPC '${method}' timed out after ${OPENCLAW_TEST_RPC_TIMEOUT_MS}ms`)),
141+
() =>
142+
reject(new Error(`RPC '${method}' timed out after ${OPENCLAW_TEST_RPC_TIMEOUT_MS}ms`)),
138143
OPENCLAW_TEST_RPC_TIMEOUT_MS,
139144
);
140145

@@ -214,27 +219,26 @@ function testOpenclawGateway(
214219
// ── Step 2: WebSocket connect ───────────────────────────────────
215220
const connectStart = Date.now();
216221
try {
217-
ws = yield* Effect.tryPromise(() =>
218-
new Promise<NodeWebSocket>((resolve, reject) => {
219-
const socket = new NodeWebSocket(gatewayUrl);
220-
const timeout = setTimeout(() => {
221-
socket.close();
222-
reject(
223-
new Error(
224-
`Connection timed out after ${OPENCLAW_TEST_CONNECT_TIMEOUT_MS}ms`,
225-
),
226-
);
227-
}, OPENCLAW_TEST_CONNECT_TIMEOUT_MS);
228-
229-
socket.on("open", () => {
230-
clearTimeout(timeout);
231-
resolve(socket);
232-
});
233-
socket.on("error", (err) => {
234-
clearTimeout(timeout);
235-
reject(err);
236-
});
237-
}),
222+
ws = yield* Effect.tryPromise(
223+
() =>
224+
new Promise<NodeWebSocket>((resolve, reject) => {
225+
const socket = new NodeWebSocket(gatewayUrl);
226+
const timeout = setTimeout(() => {
227+
socket.close();
228+
reject(
229+
new Error(`Connection timed out after ${OPENCLAW_TEST_CONNECT_TIMEOUT_MS}ms`),
230+
);
231+
}, OPENCLAW_TEST_CONNECT_TIMEOUT_MS);
232+
233+
socket.on("open", () => {
234+
clearTimeout(timeout);
235+
resolve(socket);
236+
});
237+
socket.on("error", (err) => {
238+
clearTimeout(timeout);
239+
reject(err);
240+
});
241+
}),
238242
);
239243
pushStep(
240244
"WebSocket connect",
@@ -243,8 +247,7 @@ function testOpenclawGateway(
243247
`Connected in ${Date.now() - connectStart}ms`,
244248
);
245249
} catch (err) {
246-
const detail =
247-
err instanceof Error ? err.message : "Connection failed.";
250+
const detail = err instanceof Error ? err.message : "Connection failed.";
248251
pushStep("WebSocket connect", "fail", Date.now() - connectStart, detail);
249252
return {
250253
success: false,

apps/web/src/components/ChatView.logic.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ProjectId, ThreadId } from "@okcode/contracts";
1+
import { MessageId, ProjectId, ThreadId } from "@okcode/contracts";
22
import { describe, expect, it } from "vitest";
33

44
import {
@@ -7,6 +7,7 @@ import {
77
buildHiddenProviderInput,
88
buildExpiredTerminalContextToastCopy,
99
deriveComposerSendState,
10+
findLatestRevertableUserMessageId,
1011
} from "./ChatView.logic";
1112

1213
describe("deriveComposerSendState", () => {
@@ -173,3 +174,53 @@ describe("buildLocalDraftThread", () => {
173174
expect(thread.title).toBe("New thread");
174175
});
175176
});
177+
178+
describe("findLatestRevertableUserMessageId", () => {
179+
it("returns the latest user message with a revertable turn count", () => {
180+
const target = findLatestRevertableUserMessageId(
181+
[
182+
{
183+
kind: "message",
184+
message: {
185+
id: MessageId.makeUnsafe("message-1"),
186+
role: "user",
187+
},
188+
},
189+
{
190+
kind: "message",
191+
message: {
192+
id: MessageId.makeUnsafe("message-2"),
193+
role: "assistant",
194+
},
195+
},
196+
{
197+
kind: "message",
198+
message: {
199+
id: MessageId.makeUnsafe("message-3"),
200+
role: "user",
201+
},
202+
},
203+
],
204+
new Map([[MessageId.makeUnsafe("message-3"), 2]]),
205+
);
206+
207+
expect(target).toBe(MessageId.makeUnsafe("message-3"));
208+
});
209+
210+
it("returns null when no user message can be reverted", () => {
211+
expect(
212+
findLatestRevertableUserMessageId(
213+
[
214+
{
215+
kind: "message",
216+
message: {
217+
id: MessageId.makeUnsafe("message-1"),
218+
role: "assistant",
219+
},
220+
},
221+
],
222+
new Map(),
223+
),
224+
).toBeNull();
225+
});
226+
});

apps/web/src/components/ChatView.logic.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,30 @@ export function deriveComposerSendState(options: {
168168
};
169169
}
170170

171+
export function findLatestRevertableUserMessageId(
172+
timelineEntries: ReadonlyArray<{
173+
kind: string;
174+
message?: {
175+
id: MessageId;
176+
role?: string;
177+
};
178+
}>,
179+
revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>,
180+
): MessageId | null {
181+
for (let index = timelineEntries.length - 1; index >= 0; index -= 1) {
182+
const entry = timelineEntries[index];
183+
if (!entry || entry.kind !== "message" || entry.message?.role !== "user") {
184+
continue;
185+
}
186+
187+
if (revertTurnCountByUserMessageId.has(entry.message.id)) {
188+
return entry.message.id;
189+
}
190+
}
191+
192+
return null;
193+
}
194+
171195
export function buildHiddenProviderInput(options: {
172196
prompt: string;
173197
terminalContexts: ReadonlyArray<TerminalContextDraft>;

apps/web/src/components/ChatView.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ import {
119119
LockOpenIcon,
120120
PaperclipIcon,
121121
PictureInPicture2Icon,
122+
Undo2Icon,
122123
XIcon,
123124
} from "lucide-react";
124125
import { Button } from "./ui/button";
@@ -215,6 +216,7 @@ import {
215216
cloneComposerAttachmentForRetry,
216217
collectUserMessageBlobPreviewUrls,
217218
deriveComposerSendState,
219+
findLatestRevertableUserMessageId,
218220
LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
219221
LastInvokedScriptByProjectSchema,
220222
PullRequestDialogState,
@@ -1219,6 +1221,10 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
12191221

12201222
return byUserMessageId;
12211223
}, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
1224+
const latestRevertableUserMessageId = useMemo(
1225+
() => findLatestRevertableUserMessageId(timelineEntries, revertTurnCountByUserMessageId),
1226+
[revertTurnCountByUserMessageId, timelineEntries],
1227+
);
12221228

12231229
const completionSummary = useMemo(() => {
12241230
if (!latestTurnSettled) return null;
@@ -5322,6 +5328,21 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
53225328
ref={composerFooterActionsRef}
53235329
className="flex shrink-0 items-center gap-2"
53245330
>
5331+
{latestRevertableUserMessageId ? (
5332+
<Button
5333+
variant="outline"
5334+
size="sm"
5335+
type="button"
5336+
className="rounded-full px-3 text-muted-foreground/75 hover:text-foreground/85"
5337+
onClick={() => onRevertUserMessage(latestRevertableUserMessageId)}
5338+
disabled={isRevertingCheckpoint || isWorking}
5339+
title="Undo the latest revertable turn"
5340+
aria-label="Undo the latest revertable turn"
5341+
>
5342+
<Undo2Icon className="size-3.5" />
5343+
<span>Undo</span>
5344+
</Button>
5345+
) : null}
53255346
{pendingUserInputs.length === 0 && (
53265347
<>
53275348
<PromptEnhancer

apps/web/src/routes/_chat.settings.tsx

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -402,8 +402,9 @@ function SettingsRouteView() {
402402
const [fontSizeOverride, setFontSizeOverrideState] = useState<number | null>(() =>
403403
getStoredFontSizeOverride(),
404404
);
405-
const [openclawTestResult, setOpenclawTestResult] =
406-
useState<TestOpenclawGatewayResult | null>(null);
405+
const [openclawTestResult, setOpenclawTestResult] = useState<TestOpenclawGatewayResult | null>(
406+
null,
407+
);
407408
const [openclawTestLoading, setOpenclawTestLoading] = useState(false);
408409

409410
const globalEnvironmentVariablesQuery = useQuery(globalEnvironmentVariablesQueryOptions());
@@ -2173,9 +2174,7 @@ function SettingsRouteView() {
21732174
<span
21742175
className={cn(
21752176
"text-xs font-semibold",
2176-
openclawTestResult.success
2177-
? "text-emerald-500"
2178-
: "text-red-500",
2177+
openclawTestResult.success ? "text-emerald-500" : "text-red-500",
21792178
)}
21802179
>
21812180
{openclawTestResult.success
@@ -2203,9 +2202,7 @@ function SettingsRouteView() {
22032202
)}
22042203
<div className="min-w-0 flex-1">
22052204
<div className="flex items-baseline gap-2">
2206-
<span className="font-medium text-foreground">
2207-
{step.name}
2208-
</span>
2205+
<span className="font-medium text-foreground">{step.name}</span>
22092206
<span className="tabular-nums text-muted-foreground text-[10px]">
22102207
{step.durationMs}ms
22112208
</span>
@@ -2250,9 +2247,7 @@ function SettingsRouteView() {
22502247

22512248
{/* Error summary */}
22522249
{openclawTestResult.error &&
2253-
!openclawTestResult.steps.some(
2254-
(s) => s.status === "fail",
2255-
) && (
2250+
!openclawTestResult.steps.some((s) => s.status === "fail") && (
22562251
<div className="mt-2 text-xs text-red-500">
22572252
{openclawTestResult.error}
22582253
</div>

packages/contracts/src/ipc.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -432,9 +432,7 @@ export interface NativeApi {
432432
input: SaveProjectEnvironmentVariablesInput,
433433
) => Promise<ProjectEnvironmentVariablesResult>;
434434
upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
435-
testOpenclawGateway: (
436-
input: TestOpenclawGatewayInput,
437-
) => Promise<TestOpenclawGatewayResult>;
435+
testOpenclawGateway: (input: TestOpenclawGatewayInput) => Promise<TestOpenclawGatewayResult>;
438436
};
439437
orchestration: {
440438
getSnapshot: () => Promise<OrchestrationReadModel>;

0 commit comments

Comments
 (0)