Skip to content

Commit 5fdb0cd

Browse files
authored
feat(chat): switch between session transcript branches from the chat header (#110857)
* feat(chat): switch between session transcript branches from the chat header * fix(chat): type fixes and protocol regen for branch switcher CI
1 parent 64019bc commit 5fdb0cd

27 files changed

Lines changed: 1131 additions & 69 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+
SessionsBranchesList("sessions.branches.list"),
307+
SessionsBranchesSwitch("sessions.branches.switch"),
306308
SessionsRewind("sessions.rewind"),
307309
SessionsFork("sessions.fork"),
308310
SessionsCreate("sessions.create"),

docs/web/control-ui.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ The macOS app keeps its native link-browser sidebar for links clicked in the das
392392
- 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.
393393
- 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.
394394
- 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.
395-
- 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. The separate hide action on user bubbles hides a message in the current browser only; the message stays in the transcript and the agent still sees it.
395+
- 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. When that store contains multiple transcript branches, the chat title bar shows a branch menu with each branch's latest message, message count, and recency; selecting an inactive branch switches the current session back to that preserved path (`sessions.branches.list`, `operator.read`; `sessions.branches.switch`, `operator.admin`). Branch switching is also unavailable while the agent is working, and selecting the already-active branch is a typed no-op error at the RPC boundary. The separate hide action on user bubbles hides a message in the current browser only; the message stays in the transcript and the agent still sees it.
396396
- 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.
397397
- 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`.
398398
- 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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,13 +358,18 @@ import {
358358
SendParamsSchema,
359359
SecretsResolveParamsSchema,
360360
SecretsResolveResultSchema,
361+
SessionBranchSchema,
361362
SessionsAbortParamsSchema,
362363
SessionsCompactParamsSchema,
363364
SessionsCleanupParamsSchema,
364365
SessionsCompactionBranchParamsSchema,
365366
SessionsCompactionGetParamsSchema,
366367
SessionsCompactionListParamsSchema,
367368
SessionsCompactionRestoreParamsSchema,
369+
SessionsBranchesListParamsSchema,
370+
SessionsBranchesListResultSchema,
371+
SessionsBranchesSwitchParamsSchema,
372+
SessionsBranchesSwitchResultSchema,
368373
SessionsForkParamsSchema,
369374
SessionsForkResultSchema,
370375
SessionsRewindParamsSchema,
@@ -725,6 +730,8 @@ export const validateSessionsCompactionBranchParams = lazyCompile(
725730
export const validateSessionsCompactionRestoreParams = lazyCompile(
726731
SessionsCompactionRestoreParamsSchema,
727732
);
733+
export const validateSessionsBranchesListParams = lazyCompile(SessionsBranchesListParamsSchema);
734+
export const validateSessionsBranchesSwitchParams = lazyCompile(SessionsBranchesSwitchParamsSchema);
728735
export const validateSessionsRewindParams = lazyCompile(SessionsRewindParamsSchema);
729736
export const validateSessionsForkParams = lazyCompile(SessionsForkParamsSchema);
730737
export const validateSessionsUsageParams = lazyCompile(SessionsUsageParamsSchema);
@@ -1078,6 +1085,11 @@ export {
10781085
SessionsCompactionGetParamsSchema,
10791086
SessionsCompactionBranchParamsSchema,
10801087
SessionsCompactionRestoreParamsSchema,
1088+
SessionBranchSchema,
1089+
SessionsBranchesListParamsSchema,
1090+
SessionsBranchesListResultSchema,
1091+
SessionsBranchesSwitchParamsSchema,
1092+
SessionsBranchesSwitchResultSchema,
10811093
SessionsForkParamsSchema,
10821094
SessionsForkResultSchema,
10831095
SessionsRewindParamsSchema,
@@ -1687,6 +1699,11 @@ export type {
16871699
SessionsReclaimParams,
16881700
SessionsReclaimResult,
16891701
SessionsCreateResult,
1702+
SessionBranch,
1703+
SessionsBranchesListParams,
1704+
SessionsBranchesListResult,
1705+
SessionsBranchesSwitchParams,
1706+
SessionsBranchesSwitchResult,
16901707
SessionsForkParams,
16911708
SessionsForkResult,
16921709
SessionsRewindParams,

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,33 @@ export const SessionsForkResultSchema = closedObject({
548548
editorText: Type.Optional(Type.String()),
549549
});
550550

551+
export const SessionBranchSchema = closedObject({
552+
leafEntryId: NonEmptyString,
553+
headline: Type.String(),
554+
messageCount: Type.Integer({ minimum: 0 }),
555+
updatedAt: Type.Optional(NonEmptyString),
556+
active: Type.Boolean(),
557+
});
558+
559+
/** Lists transcript DAG tips available for branch switching. */
560+
export const SessionsBranchesListParamsSchema = closedObject({
561+
sessionKey: NonEmptyString,
562+
agentId: Type.Optional(NonEmptyString),
563+
});
564+
565+
export const SessionsBranchesListResultSchema = closedObject({
566+
branches: Type.Array(SessionBranchSchema),
567+
});
568+
569+
/** Repoints the active transcript path to one existing DAG tip. */
570+
export const SessionsBranchesSwitchParamsSchema = closedObject({
571+
sessionKey: NonEmptyString,
572+
agentId: Type.Optional(NonEmptyString),
573+
leafEntryId: NonEmptyString,
574+
});
575+
576+
export const SessionsBranchesSwitchResultSchema = closedObject({});
577+
551578
/** List response for session compaction checkpoints. */
552579
export const SessionsCompactionListResultSchema = closedObject({
553580
ok: Type.Literal(true),
@@ -657,6 +684,11 @@ export type SessionsRewindParams = Static<typeof SessionsRewindParamsSchema>;
657684
export type SessionsForkParams = Static<typeof SessionsForkParamsSchema>;
658685
export type SessionsRewindResult = Static<typeof SessionsRewindResultSchema>;
659686
export type SessionsForkResult = Static<typeof SessionsForkResultSchema>;
687+
export type SessionBranch = Static<typeof SessionBranchSchema>;
688+
export type SessionsBranchesListParams = Static<typeof SessionsBranchesListParamsSchema>;
689+
export type SessionsBranchesListResult = Static<typeof SessionsBranchesListResultSchema>;
690+
export type SessionsBranchesSwitchParams = Static<typeof SessionsBranchesSwitchParamsSchema>;
691+
export type SessionsBranchesSwitchResult = Static<typeof SessionsBranchesSwitchResultSchema>;
660692
export type SessionWorktreeInfo = Static<typeof SessionWorktreeInfoSchema>;
661693
export type SessionsCreateParams = Static<typeof SessionsCreateParamsSchema>;
662694
export type SessionsCreateResult = Static<typeof SessionsCreateResultSchema>;

src/config/sessions/session-accessor.message-cut.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
11
import {
22
forkSqliteSessionAtMessage,
3+
listSqliteSessionBranches,
34
rewindSqliteSessionToMessage,
5+
switchSqliteSessionBranch,
46
} from "./session-accessor.sqlite.js";
57
import type {
8+
SessionBranchListParams,
9+
SessionBranchListResult,
10+
SessionBranchSwitchMutationParams,
11+
SessionBranchSwitchMutationResult,
612
SessionMessageCutMutationParams,
713
SessionMessageCutMutationResult,
814
} from "./session-accessor.types.js";
915

16+
export async function listSessionBranches(
17+
params: SessionBranchListParams,
18+
): Promise<SessionBranchListResult> {
19+
return await listSqliteSessionBranches(params);
20+
}
21+
1022
export async function rewindSessionToMessage(
1123
params: SessionMessageCutMutationParams,
1224
): Promise<SessionMessageCutMutationResult> {
@@ -18,3 +30,9 @@ export async function forkSessionAtMessage(
1830
): Promise<SessionMessageCutMutationResult> {
1931
return await forkSqliteSessionAtMessage(params);
2032
}
33+
34+
export async function switchSessionBranch(
35+
params: SessionBranchSwitchMutationParams,
36+
): Promise<SessionBranchSwitchMutationResult> {
37+
return await switchSqliteSessionBranch(params);
38+
}

src/config/sessions/session-accessor.sqlite-message-cut.test.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import {
66
appendTranscriptEvent,
77
appendTranscriptMessage,
88
forkSessionAtMessage,
9+
listSessionBranches,
910
loadSessionEntry,
1011
loadTranscriptEvents,
1112
readSessionTranscriptMessageEventCount,
13+
readSessionTranscriptMessageEvents,
1214
rewindSessionToMessage,
15+
switchSessionBranch,
1316
upsertSessionEntry,
1417
} from "./session-accessor.js";
1518

@@ -22,7 +25,7 @@ afterEach(() => {
2225
closeOpenClawStateDatabaseForTest();
2326
});
2427

25-
async function createSession() {
28+
async function createSession(options: { activeLeafTarget?: string } = {}) {
2629
const stateDir = tempDirs.make("openclaw-message-cut-");
2730
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
2831
const sessionId = "message-cut-source";
@@ -86,13 +89,14 @@ async function createSession() {
8689
id: "active-leaf",
8790
parentId: "off-path-user",
8891
timestamp: "2026-07-18T00:00:06.000Z",
89-
targetId: "assistant-2",
92+
targetId: options.activeLeafTarget ?? "assistant-2",
9093
},
9194
]) {
9295
if (event.type === "message") {
9396
await appendTranscriptMessage(scope, {
9497
eventId: event.id,
9598
message: event.message,
99+
now: Date.parse(event.timestamp),
96100
parentId: event.parentId,
97101
});
98102
} else {
@@ -103,6 +107,80 @@ async function createSession() {
103107
}
104108

105109
describe("SQLite session message cuts", () => {
110+
it("lists every DAG tip with active state, headline, count, and timestamp", async () => {
111+
const { env } = await createSession({ activeLeafTarget: "assistant-1" });
112+
113+
await expect(listSessionBranches({ agentId, env, sessionKey })).resolves.toEqual({
114+
status: "ok",
115+
branches: [
116+
{
117+
leafEntryId: "assistant-1",
118+
headline: "first answer",
119+
messageCount: 2,
120+
updatedAt: "2026-07-18T00:00:02.000Z",
121+
active: true,
122+
},
123+
{
124+
leafEntryId: "off-path-user",
125+
headline: "inactive prompt",
126+
messageCount: 2,
127+
updatedAt: "2026-07-18T00:00:05.000Z",
128+
active: false,
129+
},
130+
{
131+
leafEntryId: "assistant-2",
132+
headline: "second answer",
133+
messageCount: 4,
134+
updatedAt: "2026-07-18T00:00:04.000Z",
135+
active: false,
136+
},
137+
],
138+
});
139+
});
140+
141+
it("switches to another tip and rebuilds the active-path projection", async () => {
142+
const { env } = await createSession();
143+
144+
const result = await switchSessionBranch({
145+
agentId,
146+
env,
147+
leafEntryId: "off-path-user",
148+
sessionKey,
149+
});
150+
151+
expect(result).toMatchObject({ status: "created", key: sessionKey });
152+
if (result.status !== "created") {
153+
throw new Error("expected branch switch result");
154+
}
155+
const activeEventIds = readSessionTranscriptMessageEvents({
156+
agentId,
157+
env,
158+
sessionId: result.entry.sessionId,
159+
sessionKey,
160+
}).map(({ event }) =>
161+
event && typeof event === "object" && "id" in event ? event.id : undefined,
162+
);
163+
expect(activeEventIds).toEqual(["user-1", "off-path-user"]);
164+
expect(result.entry).toMatchObject({
165+
agentHarnessId: undefined,
166+
claudeCliSessionId: undefined,
167+
cliSessionBindings: undefined,
168+
cliSessionIds: undefined,
169+
});
170+
});
171+
172+
it.each([
173+
["unknown", "missing-entry"],
174+
["user-1", "not-branch-tip"],
175+
["assistant-2", "already-active"],
176+
])("rejects branch switch target %s with %s", async (leafEntryId, status) => {
177+
const { env } = await createSession();
178+
179+
await expect(
180+
switchSessionBranch({ agentId, env, leafEntryId, sessionKey }),
181+
).resolves.toMatchObject({ status });
182+
});
183+
106184
it("rewinds by repointing the active leaf and returns the editor text", async () => {
107185
const { env } = await createSession();
108186

@@ -228,5 +306,11 @@ describe("SQLite session message cuts", () => {
228306
await expect(
229307
rewindSessionToMessage({ agentId, env, entryId: "user-2", sessionKey }),
230308
).resolves.toMatchObject({ status: "unsupported-storage" });
309+
await expect(listSessionBranches({ agentId, env, sessionKey })).resolves.toMatchObject({
310+
status: "unsupported-storage",
311+
});
312+
await expect(
313+
switchSessionBranch({ agentId, env, leafEntryId: "assistant-2", sessionKey }),
314+
).resolves.toMatchObject({ status: "unsupported-storage" });
231315
});
232316
});

0 commit comments

Comments
 (0)