Skip to content

Commit 0acece4

Browse files
authored
feat(chat): rewind and fork a session from a message bubble (#110660)
* feat(chat): rewind and fork a session from a message bubble * docs(web): document chat rewind and fork bubble actions * fix(chat): lint cleanups, protocol regen, and test splits for rewind/fork CI * fix(ui): fit chat rewind disabled styles inside the startup CSS budget
1 parent 9710a13 commit 0acece4

35 files changed

Lines changed: 2043 additions & 118 deletions

apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,8 @@ enum class GatewayMethod(
303303
SessionsCompactionGet("sessions.compaction.get"),
304304
SessionsCompactionBranch("sessions.compaction.branch"),
305305
SessionsCompactionRestore("sessions.compaction.restore"),
306+
SessionsRewind("sessions.rewind"),
307+
SessionsFork("sessions.fork"),
306308
SessionsCreate("sessions.create"),
307309
SessionsSend("sessions.send"),
308310
SessionsAbort("sessions.abort"),

docs/web/control-ui.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ The macOS app keeps its native link-browser sidebar for links clicked in the das
388388
- In the macOS app, the OpenClaw mark uses the otherwise-empty native titlebar strip next to the window controls instead of consuming a sidebar row.
389389
- On desktop widths, chat controls stay on one compact row and collapse while scrolling down the transcript; scrolling up, returning to the top, or reaching the bottom restores the controls.
390390
- Consecutive duplicate text-only messages render as one bubble with a count badge. Messages that carry images, attachments, tool output, or canvas previews are left uncollapsed.
391+
- User-message bubbles carry transcript actions: a hover rewind button (confirm popover with a "Don't ask again" option) plus right-click **Rewind to here** and **Fork from here**. Rewind repoints the session to the state just before that message and returns its text to the composer for edit and resend (`sessions.rewind`, `operator.admin`); fork creates a new session from the active-path prefix before the message, opens it, and seeds its composer with the same text (`sessions.fork`, `operator.write`). Both actions disable with an explanatory tooltip while the agent is working, apply only to persisted user messages, and are rejected for sessions whose conversation is owned by an external agent harness. Rewind moves chat context only — files and other tool side effects are not reverted — and the pre-rewind transcript remains preserved in the append-only session store.
391392
- When a session's checkout sits on a non-default branch of a GitHub repository, the chat view pins pull request chips above the composer: PR number, repo, branch, diff counts, a CI pill, and draft/merged/closed state, each linking to the PR. The row shows at most two chips — live (open/draft) PRs first — and a "Show more" button reveals collapsed merged/closed history. The CI pill opens a small CI monitoring popover with passed/failed/running/skipped check counts and a link to the PR's checks page. Detection runs server-side through `controlUi.sessionPullRequests`, which reuses the Gateway's `GH_TOKEN`/`GITHUB_TOKEN` when set. When the GitHub API rate limit is hit, chips keep the last known status and show a warning that the status may be out of date; dismissing a chip hides it for that session in the current browser profile. Before any PR exists, the row shows the branch itself — repo, branch name, and the +/− size of the diff against the default-branch merge base (committed and uncommitted work). Once the pushed branch has commits to compare, the row adds a Create PR button that opens GitHub's new-pull-request page; before that, a session with changed files (committed, uncommitted, or untracked) still gets the row without the button. The row hides itself while an open or draft PR exists. The branch row comes from local git only, so it stays available while GitHub is rate limited and carries the same stale-status warning, since "no PR found" cannot be trusted until the limit resets.
392393
- The session diff panel shows what a session's checkout actually changed: the branch button in the workspace rail or chat title bar opens the detail panel with a per-file diff of branch, uncommitted, and untracked work against the checkout's default-branch merge base — status dot, rename arrow, per-file +/− counts, collapsible files, and "N unmodified lines" markers between hunks. Diffs are computed server-side through the `sessions.diff` Gateway method (`operator.read` scope); binary and oversized files degrade to stats-only entries, and the button only appears when the connected Gateway advertises `sessions.diff`.
393394
- Every Chat pane has a title bar. Click the session title to rename it; the workspace chip copies the checkout path or branch and can reveal local Gateway workspaces in the host file manager. Remote and exec-node sessions keep copy actions but hide reveal.

packages/gateway-protocol/src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,10 @@ import {
364364
SessionsCompactionGetParamsSchema,
365365
SessionsCompactionListParamsSchema,
366366
SessionsCompactionRestoreParamsSchema,
367+
SessionsForkParamsSchema,
368+
SessionsForkResultSchema,
369+
SessionsRewindParamsSchema,
370+
SessionsRewindResultSchema,
367371
SessionFileBrowserEntrySchema,
368372
SessionFileBrowserResultSchema,
369373
SessionFileEntrySchema,
@@ -720,6 +724,8 @@ export const validateSessionsCompactionBranchParams = lazyCompile(
720724
export const validateSessionsCompactionRestoreParams = lazyCompile(
721725
SessionsCompactionRestoreParamsSchema,
722726
);
727+
export const validateSessionsRewindParams = lazyCompile(SessionsRewindParamsSchema);
728+
export const validateSessionsForkParams = lazyCompile(SessionsForkParamsSchema);
723729
export const validateSessionsUsageParams = lazyCompile(SessionsUsageParamsSchema);
724730
export const validateTaskSuggestionsListParams = lazyCompile(TaskSuggestionsListParamsSchema);
725731
export const validateTaskSuggestionsCreateParams = lazyCompile(TaskSuggestionsCreateParamsSchema);
@@ -1071,6 +1077,10 @@ export {
10711077
SessionsCompactionGetParamsSchema,
10721078
SessionsCompactionBranchParamsSchema,
10731079
SessionsCompactionRestoreParamsSchema,
1080+
SessionsForkParamsSchema,
1081+
SessionsForkResultSchema,
1082+
SessionsRewindParamsSchema,
1083+
SessionsRewindResultSchema,
10741084
SessionPlacementStateSchema,
10751085
SessionPlacementSchema,
10761086
SessionWorktreeInfoSchema,
@@ -1676,6 +1686,10 @@ export type {
16761686
SessionsReclaimParams,
16771687
SessionsReclaimResult,
16781688
SessionsCreateResult,
1689+
SessionsForkParams,
1690+
SessionsForkResult,
1691+
SessionsRewindParams,
1692+
SessionsRewindResult,
16791693
SessionsPatchParams,
16801694
SessionsResetParams,
16811695
SessionsDeleteParams,

packages/gateway-protocol/src/schema/sessions.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,29 @@ export const SessionsCompactionRestoreParamsSchema = closedObject({
520520
checkpointId: NonEmptyString,
521521
});
522522

523+
/** Repoints a session to the active-path state before one persisted user message. */
524+
export const SessionsRewindParamsSchema = closedObject({
525+
sessionKey: NonEmptyString,
526+
agentId: Type.Optional(NonEmptyString),
527+
entryId: NonEmptyString,
528+
});
529+
530+
/** Creates a new session from the active-path state before one persisted user message. */
531+
export const SessionsForkParamsSchema = closedObject({
532+
sessionKey: NonEmptyString,
533+
agentId: Type.Optional(NonEmptyString),
534+
entryId: NonEmptyString,
535+
});
536+
537+
export const SessionsRewindResultSchema = closedObject({
538+
editorText: Type.Optional(Type.String()),
539+
});
540+
541+
export const SessionsForkResultSchema = closedObject({
542+
sessionKey: NonEmptyString,
543+
editorText: Type.Optional(Type.String()),
544+
});
545+
523546
/** List response for session compaction checkpoints. */
524547
export const SessionsCompactionListResultSchema = closedObject({
525548
ok: Type.Literal(true),
@@ -625,6 +648,10 @@ export type SessionsCompactionListResult = Static<typeof SessionsCompactionListR
625648
export type SessionsCompactionGetResult = Static<typeof SessionsCompactionGetResultSchema>;
626649
export type SessionsCompactionBranchResult = Static<typeof SessionsCompactionBranchResultSchema>;
627650
export type SessionsCompactionRestoreResult = Static<typeof SessionsCompactionRestoreResultSchema>;
651+
export type SessionsRewindParams = Static<typeof SessionsRewindParamsSchema>;
652+
export type SessionsForkParams = Static<typeof SessionsForkParamsSchema>;
653+
export type SessionsRewindResult = Static<typeof SessionsRewindResultSchema>;
654+
export type SessionsForkResult = Static<typeof SessionsForkResultSchema>;
628655
export type SessionWorktreeInfo = Static<typeof SessionWorktreeInfoSchema>;
629656
export type SessionsCreateParams = Static<typeof SessionsCreateParamsSchema>;
630657
export type SessionsCreateResult = Static<typeof SessionsCreateResultSchema>;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {
2+
forkSqliteSessionAtMessage,
3+
rewindSqliteSessionToMessage,
4+
} from "./session-accessor.sqlite.js";
5+
import type {
6+
SessionMessageCutMutationParams,
7+
SessionMessageCutMutationResult,
8+
} from "./session-accessor.types.js";
9+
10+
export async function rewindSessionToMessage(
11+
params: SessionMessageCutMutationParams,
12+
): Promise<SessionMessageCutMutationResult> {
13+
return await rewindSqliteSessionToMessage(params);
14+
}
15+
16+
export async function forkSessionAtMessage(
17+
params: SessionMessageCutMutationParams & { targetKey: string },
18+
): Promise<SessionMessageCutMutationResult> {
19+
return await forkSqliteSessionAtMessage(params);
20+
}
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
3+
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
4+
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
5+
import {
6+
appendTranscriptEvent,
7+
appendTranscriptMessage,
8+
forkSessionAtMessage,
9+
loadSessionEntry,
10+
loadTranscriptEvents,
11+
readSessionTranscriptMessageEventCount,
12+
rewindSessionToMessage,
13+
upsertSessionEntry,
14+
} from "./session-accessor.js";
15+
16+
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
17+
const agentId = "main";
18+
const sessionKey = "agent:main:message-cut";
19+
20+
afterEach(() => {
21+
closeOpenClawAgentDatabasesForTest();
22+
closeOpenClawStateDatabaseForTest();
23+
});
24+
25+
async function createSession() {
26+
const stateDir = tempDirs.make("openclaw-message-cut-");
27+
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
28+
const sessionId = "message-cut-source";
29+
const scope = { agentId, env, sessionId, sessionKey };
30+
await upsertSessionEntry(scope, {
31+
agentHarnessId: "embedded",
32+
claudeCliSessionId: "claude-conversation",
33+
cliSessionBindings: { "claude-cli": { sessionId: "claude-conversation" } },
34+
cliSessionIds: { "claude-cli": "claude-conversation" },
35+
compactionCount: 2,
36+
contextTokens: 100_000,
37+
deliveryContext: { channel: "telegram", to: "chat-123" },
38+
lastChannel: "telegram",
39+
lastTo: "chat-123",
40+
lifecycleRevision: "source-lifecycle-revision",
41+
modelOverride: "gpt-5",
42+
modelOverrideSource: "user",
43+
providerOverride: "openai",
44+
sessionId,
45+
updatedAt: Date.now(),
46+
});
47+
for (const event of [
48+
{ type: "session", id: sessionId, version: 3, timestamp: "2026-07-18T00:00:00.000Z" },
49+
{
50+
type: "message",
51+
id: "user-1",
52+
parentId: null,
53+
timestamp: "2026-07-18T00:00:01.000Z",
54+
message: { role: "user", content: "first prompt" },
55+
},
56+
{
57+
type: "message",
58+
id: "assistant-1",
59+
parentId: "user-1",
60+
timestamp: "2026-07-18T00:00:02.000Z",
61+
message: { role: "assistant", content: "first answer" },
62+
},
63+
{
64+
type: "message",
65+
id: "user-2",
66+
parentId: "assistant-1",
67+
timestamp: "2026-07-18T00:00:03.000Z",
68+
message: { role: "user", content: [{ type: "text", text: "second prompt" }] },
69+
},
70+
{
71+
type: "message",
72+
id: "assistant-2",
73+
parentId: "user-2",
74+
timestamp: "2026-07-18T00:00:04.000Z",
75+
message: { role: "assistant", content: "second answer" },
76+
},
77+
{
78+
type: "message",
79+
id: "off-path-user",
80+
parentId: "user-1",
81+
timestamp: "2026-07-18T00:00:05.000Z",
82+
message: { role: "user", content: "inactive prompt" },
83+
},
84+
{
85+
type: "leaf",
86+
id: "active-leaf",
87+
parentId: "off-path-user",
88+
timestamp: "2026-07-18T00:00:06.000Z",
89+
targetId: "assistant-2",
90+
},
91+
]) {
92+
if (event.type === "message") {
93+
await appendTranscriptMessage(scope, {
94+
eventId: event.id,
95+
message: event.message,
96+
parentId: event.parentId,
97+
});
98+
} else {
99+
await appendTranscriptEvent(scope, event);
100+
}
101+
}
102+
return { env, scope };
103+
}
104+
105+
describe("SQLite session message cuts", () => {
106+
it("rewinds by repointing the active leaf and returns the editor text", async () => {
107+
const { env } = await createSession();
108+
109+
const result = await rewindSessionToMessage({
110+
agentId,
111+
env,
112+
entryId: "user-2",
113+
sessionKey,
114+
});
115+
116+
expect(result).toMatchObject({
117+
status: "created",
118+
key: sessionKey,
119+
editorText: "second prompt",
120+
});
121+
if (result.status !== "created") {
122+
throw new Error("expected rewind result");
123+
}
124+
expect(
125+
readSessionTranscriptMessageEventCount({ agentId, env, sessionId: result.entry.sessionId }),
126+
).toBe(2);
127+
expect(loadSessionEntry({ agentId, env, sessionKey })?.sessionId).toBe(result.entry.sessionId);
128+
expect(result.entry).toMatchObject({
129+
agentHarnessId: undefined,
130+
claudeCliSessionId: undefined,
131+
cliSessionBindings: undefined,
132+
cliSessionIds: undefined,
133+
compactionCount: undefined,
134+
contextTokens: undefined,
135+
});
136+
expect(result.entry.deliveryContext).toEqual({ channel: "telegram", to: "chat-123" });
137+
});
138+
139+
it("rewinds the stored row when its canonical key differs", async () => {
140+
const { env } = await createSession();
141+
const canonicalKey = "agent:main:canonical-message-cut";
142+
143+
const result = await rewindSessionToMessage({
144+
agentId,
145+
env,
146+
entryId: "user-2",
147+
sessionKey: canonicalKey,
148+
sessionStoreKey: sessionKey,
149+
});
150+
151+
expect(result).toMatchObject({ status: "created", key: sessionKey });
152+
if (result.status !== "created") {
153+
throw new Error("expected rewind result");
154+
}
155+
expect(loadSessionEntry({ agentId, env, sessionKey })?.sessionId).toBe(result.entry.sessionId);
156+
expect(loadSessionEntry({ agentId, env, sessionKey: canonicalKey })).toBeUndefined();
157+
});
158+
159+
it("forks an exact active-path prefix without changing the source", async () => {
160+
const { env, scope } = await createSession();
161+
const canonicalSourceKey = "agent:main:canonical-message-cut-source";
162+
const targetKey = "agent:main:dashboard:message-cut-fork";
163+
164+
const result = await forkSessionAtMessage({
165+
agentId,
166+
env,
167+
entryId: "user-2",
168+
sessionKey: canonicalSourceKey,
169+
sessionStoreKey: sessionKey,
170+
targetKey,
171+
});
172+
173+
expect(result).toMatchObject({
174+
status: "created",
175+
key: targetKey,
176+
editorText: "second prompt",
177+
});
178+
if (result.status !== "created") {
179+
throw new Error("expected fork result");
180+
}
181+
const forkEvents = await loadTranscriptEvents({
182+
agentId,
183+
env,
184+
sessionId: result.entry.sessionId,
185+
sessionKey: targetKey,
186+
});
187+
expect(
188+
forkEvents.flatMap((event) =>
189+
event && typeof event === "object" && "id" in event ? [event.id] : [],
190+
),
191+
).toEqual([result.entry.sessionId, "user-1", "assistant-1"]);
192+
expect(loadSessionEntry(scope)?.sessionId).toBe(scope.sessionId);
193+
expect(result.entry.lifecycleRevision).not.toBe("source-lifecycle-revision");
194+
expect(result.entry.cliSessionBindings).toBeUndefined();
195+
expect(result.entry.deliveryContext).toBeUndefined();
196+
expect(result.entry.lastChannel).toBeUndefined();
197+
expect(result.entry.lastTo).toBeUndefined();
198+
expect(result.entry.parentSessionKey).toBe(canonicalSourceKey);
199+
expect(result.entry).toMatchObject({
200+
modelOverride: "gpt-5",
201+
modelOverrideSource: "user",
202+
providerOverride: "openai",
203+
});
204+
expect(loadSessionEntry(scope)?.lifecycleRevision).toBe("source-lifecycle-revision");
205+
});
206+
207+
it.each([
208+
["unknown", "missing-entry"],
209+
["assistant-1", "not-user-message"],
210+
["off-path-user", "off-active-path"],
211+
])("rejects %s with %s", async (entryId, status) => {
212+
const { env } = await createSession();
213+
214+
await expect(
215+
rewindSessionToMessage({ agentId, env, entryId, sessionKey }),
216+
).resolves.toMatchObject({ status });
217+
});
218+
219+
it("returns a typed error for legacy JSONL transcript storage", async () => {
220+
const { env } = await createSession();
221+
await upsertSessionEntry(
222+
{ agentId, env, sessionKey },
223+
{
224+
sessionFile: "/tmp/legacy-session.jsonl",
225+
},
226+
);
227+
228+
await expect(
229+
rewindSessionToMessage({ agentId, env, entryId: "user-2", sessionKey }),
230+
).resolves.toMatchObject({ status: "unsupported-storage" });
231+
});
232+
});

0 commit comments

Comments
 (0)