diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 0a0d9bbc33f2..f8e940305096 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -303,6 +303,8 @@ enum class GatewayMethod( SessionsCompactionGet("sessions.compaction.get"), SessionsCompactionBranch("sessions.compaction.branch"), SessionsCompactionRestore("sessions.compaction.restore"), + SessionsBranchesList("sessions.branches.list"), + SessionsBranchesSwitch("sessions.branches.switch"), SessionsRewind("sessions.rewind"), SessionsFork("sessions.fork"), SessionsCreate("sessions.create"), diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 054bd203f884..074c6eedef8b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -392,7 +392,7 @@ The macOS app keeps its native link-browser sidebar for links clicked in the das - 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. - 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. - 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. - - 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. + - 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. - 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. - 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`. - 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. diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index cc6a5625f5b4..ae17082db44b 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -358,6 +358,7 @@ import { SendParamsSchema, SecretsResolveParamsSchema, SecretsResolveResultSchema, + SessionBranchSchema, SessionsAbortParamsSchema, SessionsCompactParamsSchema, SessionsCleanupParamsSchema, @@ -365,6 +366,10 @@ import { SessionsCompactionGetParamsSchema, SessionsCompactionListParamsSchema, SessionsCompactionRestoreParamsSchema, + SessionsBranchesListParamsSchema, + SessionsBranchesListResultSchema, + SessionsBranchesSwitchParamsSchema, + SessionsBranchesSwitchResultSchema, SessionsForkParamsSchema, SessionsForkResultSchema, SessionsRewindParamsSchema, @@ -725,6 +730,8 @@ export const validateSessionsCompactionBranchParams = lazyCompile( export const validateSessionsCompactionRestoreParams = lazyCompile( SessionsCompactionRestoreParamsSchema, ); +export const validateSessionsBranchesListParams = lazyCompile(SessionsBranchesListParamsSchema); +export const validateSessionsBranchesSwitchParams = lazyCompile(SessionsBranchesSwitchParamsSchema); export const validateSessionsRewindParams = lazyCompile(SessionsRewindParamsSchema); export const validateSessionsForkParams = lazyCompile(SessionsForkParamsSchema); export const validateSessionsUsageParams = lazyCompile(SessionsUsageParamsSchema); @@ -1078,6 +1085,11 @@ export { SessionsCompactionGetParamsSchema, SessionsCompactionBranchParamsSchema, SessionsCompactionRestoreParamsSchema, + SessionBranchSchema, + SessionsBranchesListParamsSchema, + SessionsBranchesListResultSchema, + SessionsBranchesSwitchParamsSchema, + SessionsBranchesSwitchResultSchema, SessionsForkParamsSchema, SessionsForkResultSchema, SessionsRewindParamsSchema, @@ -1687,6 +1699,11 @@ export type { SessionsReclaimParams, SessionsReclaimResult, SessionsCreateResult, + SessionBranch, + SessionsBranchesListParams, + SessionsBranchesListResult, + SessionsBranchesSwitchParams, + SessionsBranchesSwitchResult, SessionsForkParams, SessionsForkResult, SessionsRewindParams, diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 6f326128d870..42dffa29bcd4 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -548,6 +548,33 @@ export const SessionsForkResultSchema = closedObject({ editorText: Type.Optional(Type.String()), }); +export const SessionBranchSchema = closedObject({ + leafEntryId: NonEmptyString, + headline: Type.String(), + messageCount: Type.Integer({ minimum: 0 }), + updatedAt: Type.Optional(NonEmptyString), + active: Type.Boolean(), +}); + +/** Lists transcript DAG tips available for branch switching. */ +export const SessionsBranchesListParamsSchema = closedObject({ + sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), +}); + +export const SessionsBranchesListResultSchema = closedObject({ + branches: Type.Array(SessionBranchSchema), +}); + +/** Repoints the active transcript path to one existing DAG tip. */ +export const SessionsBranchesSwitchParamsSchema = closedObject({ + sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), + leafEntryId: NonEmptyString, +}); + +export const SessionsBranchesSwitchResultSchema = closedObject({}); + /** List response for session compaction checkpoints. */ export const SessionsCompactionListResultSchema = closedObject({ ok: Type.Literal(true), @@ -657,6 +684,11 @@ export type SessionsRewindParams = Static; export type SessionsForkParams = Static; export type SessionsRewindResult = Static; export type SessionsForkResult = Static; +export type SessionBranch = Static; +export type SessionsBranchesListParams = Static; +export type SessionsBranchesListResult = Static; +export type SessionsBranchesSwitchParams = Static; +export type SessionsBranchesSwitchResult = Static; export type SessionWorktreeInfo = Static; export type SessionsCreateParams = Static; export type SessionsCreateResult = Static; diff --git a/src/config/sessions/session-accessor.message-cut.ts b/src/config/sessions/session-accessor.message-cut.ts index 4239c2966dfd..2bfbee4c7eb2 100644 --- a/src/config/sessions/session-accessor.message-cut.ts +++ b/src/config/sessions/session-accessor.message-cut.ts @@ -1,12 +1,24 @@ import { forkSqliteSessionAtMessage, + listSqliteSessionBranches, rewindSqliteSessionToMessage, + switchSqliteSessionBranch, } from "./session-accessor.sqlite.js"; import type { + SessionBranchListParams, + SessionBranchListResult, + SessionBranchSwitchMutationParams, + SessionBranchSwitchMutationResult, SessionMessageCutMutationParams, SessionMessageCutMutationResult, } from "./session-accessor.types.js"; +export async function listSessionBranches( + params: SessionBranchListParams, +): Promise { + return await listSqliteSessionBranches(params); +} + export async function rewindSessionToMessage( params: SessionMessageCutMutationParams, ): Promise { @@ -18,3 +30,9 @@ export async function forkSessionAtMessage( ): Promise { return await forkSqliteSessionAtMessage(params); } + +export async function switchSessionBranch( + params: SessionBranchSwitchMutationParams, +): Promise { + return await switchSqliteSessionBranch(params); +} diff --git a/src/config/sessions/session-accessor.sqlite-message-cut.test.ts b/src/config/sessions/session-accessor.sqlite-message-cut.test.ts index 4b60e4b313fe..5af4dfec76d1 100644 --- a/src/config/sessions/session-accessor.sqlite-message-cut.test.ts +++ b/src/config/sessions/session-accessor.sqlite-message-cut.test.ts @@ -6,10 +6,13 @@ import { appendTranscriptEvent, appendTranscriptMessage, forkSessionAtMessage, + listSessionBranches, loadSessionEntry, loadTranscriptEvents, readSessionTranscriptMessageEventCount, + readSessionTranscriptMessageEvents, rewindSessionToMessage, + switchSessionBranch, upsertSessionEntry, } from "./session-accessor.js"; @@ -22,7 +25,7 @@ afterEach(() => { closeOpenClawStateDatabaseForTest(); }); -async function createSession() { +async function createSession(options: { activeLeafTarget?: string } = {}) { const stateDir = tempDirs.make("openclaw-message-cut-"); const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; const sessionId = "message-cut-source"; @@ -86,13 +89,14 @@ async function createSession() { id: "active-leaf", parentId: "off-path-user", timestamp: "2026-07-18T00:00:06.000Z", - targetId: "assistant-2", + targetId: options.activeLeafTarget ?? "assistant-2", }, ]) { if (event.type === "message") { await appendTranscriptMessage(scope, { eventId: event.id, message: event.message, + now: Date.parse(event.timestamp), parentId: event.parentId, }); } else { @@ -103,6 +107,80 @@ async function createSession() { } describe("SQLite session message cuts", () => { + it("lists every DAG tip with active state, headline, count, and timestamp", async () => { + const { env } = await createSession({ activeLeafTarget: "assistant-1" }); + + await expect(listSessionBranches({ agentId, env, sessionKey })).resolves.toEqual({ + status: "ok", + branches: [ + { + leafEntryId: "assistant-1", + headline: "first answer", + messageCount: 2, + updatedAt: "2026-07-18T00:00:02.000Z", + active: true, + }, + { + leafEntryId: "off-path-user", + headline: "inactive prompt", + messageCount: 2, + updatedAt: "2026-07-18T00:00:05.000Z", + active: false, + }, + { + leafEntryId: "assistant-2", + headline: "second answer", + messageCount: 4, + updatedAt: "2026-07-18T00:00:04.000Z", + active: false, + }, + ], + }); + }); + + it("switches to another tip and rebuilds the active-path projection", async () => { + const { env } = await createSession(); + + const result = await switchSessionBranch({ + agentId, + env, + leafEntryId: "off-path-user", + sessionKey, + }); + + expect(result).toMatchObject({ status: "created", key: sessionKey }); + if (result.status !== "created") { + throw new Error("expected branch switch result"); + } + const activeEventIds = readSessionTranscriptMessageEvents({ + agentId, + env, + sessionId: result.entry.sessionId, + sessionKey, + }).map(({ event }) => + event && typeof event === "object" && "id" in event ? event.id : undefined, + ); + expect(activeEventIds).toEqual(["user-1", "off-path-user"]); + expect(result.entry).toMatchObject({ + agentHarnessId: undefined, + claudeCliSessionId: undefined, + cliSessionBindings: undefined, + cliSessionIds: undefined, + }); + }); + + it.each([ + ["unknown", "missing-entry"], + ["user-1", "not-branch-tip"], + ["assistant-2", "already-active"], + ])("rejects branch switch target %s with %s", async (leafEntryId, status) => { + const { env } = await createSession(); + + await expect( + switchSessionBranch({ agentId, env, leafEntryId, sessionKey }), + ).resolves.toMatchObject({ status }); + }); + it("rewinds by repointing the active leaf and returns the editor text", async () => { const { env } = await createSession(); @@ -228,5 +306,11 @@ describe("SQLite session message cuts", () => { await expect( rewindSessionToMessage({ agentId, env, entryId: "user-2", sessionKey }), ).resolves.toMatchObject({ status: "unsupported-storage" }); + await expect(listSessionBranches({ agentId, env, sessionKey })).resolves.toMatchObject({ + status: "unsupported-storage", + }); + await expect( + switchSessionBranch({ agentId, env, leafEntryId: "assistant-2", sessionKey }), + ).resolves.toMatchObject({ status: "unsupported-storage" }); }); }); diff --git a/src/config/sessions/session-accessor.sqlite-message-cut.ts b/src/config/sessions/session-accessor.sqlite-message-cut.ts index 5eb0f68794f3..50221d321bc1 100644 --- a/src/config/sessions/session-accessor.sqlite-message-cut.ts +++ b/src/config/sessions/session-accessor.sqlite-message-cut.ts @@ -1,6 +1,8 @@ import { randomUUID } from "node:crypto"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import { extractAssistantVisibleText } from "../../shared/chat-message-content.js"; import { + openOpenClawAgentDatabase, runOpenClawAgentWriteTransaction, type OpenClawAgentDatabase, } from "../../state/openclaw-agent-db.js"; @@ -23,6 +25,11 @@ import { } from "./session-accessor.sqlite-scope.js"; import { appendTranscriptEventsInTransaction } from "./session-accessor.sqlite-transcript-store.js"; import type { + SessionBranchListParams, + SessionBranchListResult, + SessionBranchSummary, + SessionBranchSwitchMutationParams, + SessionBranchSwitchMutationResult, SessionMessageCutMutationParams, SessionMessageCutMutationResult, } from "./session-accessor.types.js"; @@ -31,8 +38,10 @@ import { reconcileSessionTranscriptIndexInTransaction } from "./session-transcri import { parseSqliteSessionFileMarker } from "./sqlite-marker.js"; import { createSessionTranscriptHeader } from "./transcript-header.js"; import { + isSessionTranscriptLeafControl, scanSessionTranscriptTree, selectSessionTranscriptTreePathNodes, + type SessionTranscriptTree, } from "./transcript-tree.js"; import type { SessionEntry } from "./types.js"; @@ -42,6 +51,43 @@ type MessageCut = { prefix: TranscriptEvent[]; }; +type SessionTranscriptMutationResult = + | SessionMessageCutMutationResult + | SessionBranchSwitchMutationResult; + +type SessionTranscriptMutationMode = "fork" | "rewind" | "switch"; + +const BRANCH_HEADLINE_MAX_CHARS = 120; + +export async function listSqliteSessionBranches( + params: SessionBranchListParams, +): Promise { + const sourceKey = normalizeSqliteSessionKey(params.sessionStoreKey ?? params.sessionKey); + const resolved = resolveSqliteScope({ + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.env ? { env: params.env } : {}), + sessionKey: sourceKey, + ...(params.storePath ? { storePath: params.storePath } : {}), + }); + try { + const database = openOpenClawAgentDatabase(toDatabaseOptions(resolved)); + const currentEntry = readSessionEntryRow(database, sourceKey)?.entry; + if (!currentEntry?.sessionId) { + return { status: "missing-session" }; + } + if ( + currentEntry.sessionFile?.trim() && + !parseSqliteSessionFileMarker(currentEntry.sessionFile) + ) { + return { status: "unsupported-storage" }; + } + const events = loadSqliteTranscriptEventsFromDatabase(database, currentEntry.sessionId); + return { status: "ok", branches: summarizeSessionBranches(events) }; + } catch { + return { status: "failed" }; + } +} + export async function rewindSqliteSessionToMessage( params: SessionMessageCutMutationParams, ): Promise { @@ -54,16 +100,29 @@ export async function forkSqliteSessionAtMessage( return await mutateSqliteSessionAtMessage(params, "fork"); } -async function mutateSqliteSessionAtMessage( +export async function switchSqliteSessionBranch( + params: SessionBranchSwitchMutationParams, +): Promise { + return await mutateSqliteSessionAtMessage({ ...params, entryId: params.leafEntryId }, "switch"); +} + +function mutateSqliteSessionAtMessage( params: SessionMessageCutMutationParams, mode: "fork" | "rewind", -): Promise { +): Promise; +function mutateSqliteSessionAtMessage( + params: SessionMessageCutMutationParams, + mode: "switch", +): Promise; + +async function mutateSqliteSessionAtMessage( + params: SessionMessageCutMutationParams, + mode: SessionTranscriptMutationMode, +): Promise { const canonicalSourceKey = normalizeSqliteSessionKey(params.sessionKey); const sourceKey = normalizeSqliteSessionKey(params.sessionStoreKey ?? params.sessionKey); const targetKey = - mode === "rewind" - ? sourceKey - : normalizeSqliteSessionKey(params.targetKey ?? params.sessionKey); + mode === "fork" ? normalizeSqliteSessionKey(params.targetKey ?? params.sessionKey) : sourceKey; const resolved = resolveSqliteScope({ ...(params.agentId ? { agentId: params.agentId } : {}), ...(params.env ? { env: params.env } : {}), @@ -71,7 +130,7 @@ async function mutateSqliteSessionAtMessage( ...(params.storePath ? { storePath: params.storePath } : {}), }); return await runExclusiveSqliteSessionWrite(resolved, async () => { - let result: SessionMessageCutMutationResult = { status: "failed" }; + let result: SessionTranscriptMutationResult = { status: "failed" }; let previousIdentity = new Map(); let currentIdentity = new Map(); runOpenClawAgentWriteTransaction((database) => { @@ -100,11 +159,11 @@ function mutateSqliteSessionAtMessageInTransaction( params: { canonicalSourceKey: string; entryId: string; - mode: "fork" | "rewind"; + mode: SessionTranscriptMutationMode; sourceKey: string; targetKey: string; }, -): SessionMessageCutMutationResult { +): SessionTranscriptMutationResult { const currentEntry = readSessionEntryRow(database, params.sourceKey)?.entry; if (!currentEntry?.sessionId) { return { status: "missing-session" }; @@ -113,10 +172,16 @@ function mutateSqliteSessionAtMessageInTransaction( return { status: "unsupported-storage" }; } const events = loadSqliteTranscriptEventsFromDatabase(database, currentEntry.sessionId); - const cut = resolveMessageCut(events, params.entryId); - if ("status" in cut) { + const cut = params.mode === "switch" ? undefined : resolveMessageCut(events, params.entryId); + if (cut && "status" in cut) { return cut; } + if (params.mode === "switch") { + const tipStatus = validateBranchTip(events, params.entryId); + if (tipStatus) { + return { status: tipStatus }; + } + } const nextSessionId = randomUUID(); const targetScope = { @@ -130,7 +195,7 @@ function mutateSqliteSessionAtMessageInTransaction( sessionId: nextSessionId, }); const nextEvents = - params.mode === "fork" + params.mode === "fork" && cut && !("status" in cut) ? [header, ...cut.prefix] : [ header, @@ -140,11 +205,11 @@ function mutateSqliteSessionAtMessageInTransaction( id: uniqueEntryId(events), parentId: readLastEventId(events), timestamp: new Date().toISOString(), - targetId: cut.parentId, + targetId: params.mode === "switch" ? params.entryId : (cut?.parentId ?? null), }, ]; appendTranscriptEventsInTransaction(database, targetScope, nextEvents); - if (params.mode === "rewind") { + if (params.mode !== "fork") { reconcileSessionTranscriptIndexInTransaction(database.db, nextSessionId); } @@ -162,10 +227,95 @@ function mutateSqliteSessionAtMessageInTransaction( status: "created", key: params.targetKey, entry: nextEntry, - ...(cut.editorText ? { editorText: cut.editorText } : {}), + ...(cut && !("status" in cut) && cut.editorText ? { editorText: cut.editorText } : {}), }; } +function validateBranchTip( + events: readonly TranscriptEvent[], + entryId: string, +): "missing-entry" | "not-branch-tip" | "already-active" | undefined { + const tree = scanSessionTranscriptTree(events); + const target = tree.byId.get(entryId); + if (!target) { + return "missing-entry"; + } + if (isSessionTranscriptLeafControl(target.entry)) { + return "not-branch-tip"; + } + if (!sessionBranchTipNodes(tree).some((node) => node.id === entryId)) { + return "not-branch-tip"; + } + return tree.leafId === entryId ? "already-active" : undefined; +} + +function summarizeSessionBranches(events: readonly TranscriptEvent[]): SessionBranchSummary[] { + const tree = scanSessionTranscriptTree(events); + return sessionBranchTipNodes(tree) + .toSorted( + (left, right) => + Number(right.id === tree.leafId) - Number(left.id === tree.leafId) || + right.index - left.index, + ) + .map((node) => summarizeSessionBranch(tree, node.id)); +} + +function sessionBranchTipNodes(tree: SessionTranscriptTree) { + const referencedParents = new Set( + tree.nodes.flatMap((node) => + isSessionTranscriptLeafControl(node.entry) || node.parentId === null ? [] : [node.parentId], + ), + ); + return tree.nodes.filter( + (node) => + !isSessionTranscriptLeafControl(node.entry) && + (node.id === tree.leafId || !referencedParents.has(node.id)), + ); +} + +function summarizeSessionBranch( + tree: SessionTranscriptTree, + leafEntryId: string, +): SessionBranchSummary { + const path = selectSessionTranscriptTreePathNodes(tree, leafEntryId); + const messages = path.flatMap((node) => { + const record = asRecord(node.entry); + return record?.type === "message" ? [record] : []; + }); + const headline = messages + .toReversed() + .map((record) => extractHeadlineText(record.message)) + .find((value): value is string => value !== undefined); + const timestamp = asRecord(tree.byId.get(leafEntryId)?.entry)?.timestamp; + return { + leafEntryId, + headline: truncateBranchHeadline(headline ?? ""), + messageCount: messages.length, + ...(typeof timestamp === "string" && timestamp.trim() ? { updatedAt: timestamp } : {}), + active: tree.leafId === leafEntryId, + }; +} + +function extractHeadlineText(messageValue: unknown): string | undefined { + const message = asRecord(messageValue); + if (message?.role !== "user" && message?.role !== "assistant") { + return undefined; + } + const text = + message.role === "assistant" + ? extractAssistantVisibleText(message) + : extractEditorText(message.content ?? message.text); + const normalized = text?.replace(/\s+/g, " ").trim(); + return normalized || undefined; +} + +function truncateBranchHeadline(value: string): string { + const characters = Array.from(value); + return characters.length <= BRANCH_HEADLINE_MAX_CHARS + ? value + : `${characters.slice(0, BRANCH_HEADLINE_MAX_CHARS - 1).join("")}…`; +} + function resolveMessageCut( events: readonly TranscriptEvent[], entryId: string, diff --git a/src/config/sessions/session-accessor.sqlite.ts b/src/config/sessions/session-accessor.sqlite.ts index b5801b119291..362f385904f3 100644 --- a/src/config/sessions/session-accessor.sqlite.ts +++ b/src/config/sessions/session-accessor.sqlite.ts @@ -40,7 +40,9 @@ export { } from "./session-accessor.sqlite-checkpoint.js"; export { forkSqliteSessionAtMessage, + listSqliteSessionBranches, rewindSqliteSessionToMessage, + switchSqliteSessionBranch, } from "./session-accessor.sqlite-message-cut.js"; export { appendSqliteExpectedSessionTranscriptTurn, diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index 9ccd5967087b..f35c93335bc8 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -41,6 +41,11 @@ export type { SessionCompactionCheckpointMutationResult, SessionMessageCutMutationParams, SessionMessageCutMutationResult, + SessionBranchListParams, + SessionBranchListResult, + SessionBranchSummary, + SessionBranchSwitchMutationParams, + SessionBranchSwitchMutationResult, SessionCompactionCheckpointTranscriptForkResult, SessionCompactionCheckpointTranscriptForker, SessionEntryCandidateAccessScope, @@ -161,7 +166,12 @@ export { rollbackAgentHarnessSessionEntryLifecycle, rollbackPluginOwnedSessionEntryLifecycle, } from "./session-accessor.lifecycle.js"; -export { forkSessionAtMessage, rewindSessionToMessage } from "./session-accessor.message-cut.js"; +export { + forkSessionAtMessage, + listSessionBranches, + rewindSessionToMessage, + switchSessionBranch, +} from "./session-accessor.message-cut.js"; export { commitReplySessionInitialization, loadReplySessionInitializationSnapshot, diff --git a/src/config/sessions/session-accessor.types.ts b/src/config/sessions/session-accessor.types.ts index afbf620b76fb..f50c1a5b1d85 100644 --- a/src/config/sessions/session-accessor.types.ts +++ b/src/config/sessions/session-accessor.types.ts @@ -673,6 +673,45 @@ export type SessionMessageCutMutationParams = { targetKey?: string; }; +export type SessionBranchSummary = { + leafEntryId: string; + headline: string; + messageCount: number; + updatedAt?: string; + active: boolean; +}; + +export type SessionBranchListResult = + | { status: "ok"; branches: SessionBranchSummary[] } + | { status: "missing-session" } + | { status: "unsupported-storage" } + | { status: "failed" }; + +export type SessionBranchListParams = Pick< + SessionMessageCutMutationParams, + "agentId" | "env" | "sessionKey" | "sessionStoreKey" | "storePath" +>; + +export type SessionBranchSwitchMutationResult = + | { + status: "created"; + key: string; + entry: SessionEntry; + } + | { status: "missing-session" } + | { status: "missing-entry" } + | { status: "not-branch-tip" } + | { status: "already-active" } + | { status: "unsupported-storage" } + | { status: "failed" }; + +export type SessionBranchSwitchMutationParams = Omit< + SessionMessageCutMutationParams, + "entryId" | "targetKey" +> & { + leafEntryId: string; +}; + export type SessionCompactionCheckpointEntryBuildContext = { /** Checkpoint row selected from the current persisted session entry. */ checkpoint: SessionCompactionCheckpoint; diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 9421c5a31cf2..fb60feea4e1b 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -196,6 +196,8 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "sessions.compaction.get", scope: "operator.read" }, { name: "sessions.compaction.branch", scope: "operator.write" }, { name: "sessions.compaction.restore", scope: "operator.admin" }, + { name: "sessions.branches.list", scope: "operator.read" }, + { name: "sessions.branches.switch", scope: "operator.admin" }, { name: "sessions.rewind", scope: "operator.admin" }, { name: "sessions.fork", scope: "operator.write" }, // Params-aware: explicit cwd can point at any host checkout and requires admin. diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 34ebf9a13faa..300516e3a553 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -647,6 +647,8 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { "sessions.create", "sessions.compaction.branch", "sessions.compaction.restore", + "sessions.branches.list", + "sessions.branches.switch", "sessions.rewind", "sessions.fork", "sessions.send", diff --git a/src/gateway/server-methods/sessions-rewind.test.ts b/src/gateway/server-methods/sessions-rewind.test.ts index fd6b613c9741..15c6997371b6 100644 --- a/src/gateway/server-methods/sessions-rewind.test.ts +++ b/src/gateway/server-methods/sessions-rewind.test.ts @@ -101,14 +101,27 @@ function context(): GatewayRequestContext { } as unknown as GatewayRequestContext; } -async function invoke(method: "sessions.fork" | "sessions.rewind", entryId: string) { +type MessageCutMethod = + | "sessions.branches.list" + | "sessions.branches.switch" + | "sessions.fork" + | "sessions.rewind"; + +async function invoke(method: MessageCutMethod, entryId?: string) { const respond = vi.fn() as unknown as RespondFn; await expectDefined( sessionsHandlers[method], `${method} handler`, )({ req: { id: `${method}-request` } as never, - params: { sessionKey, entryId }, + params: { + sessionKey, + ...(method === "sessions.branches.switch" + ? { leafEntryId: entryId } + : method === "sessions.branches.list" + ? {} + : { entryId }), + }, respond, context: context(), client: null, @@ -118,6 +131,50 @@ async function invoke(method: "sessions.fork" | "sessions.rewind", entryId: stri } describe("session message-cut methods", () => { + it("lists branches and switches to an inactive tip", async () => { + const listed = await invoke("sessions.branches.list"); + expect(listed).toHaveBeenCalledWith( + true, + { + branches: [ + expect.objectContaining({ + leafEntryId: "assistant-entry", + headline: "answer", + messageCount: 2, + active: true, + }), + expect.objectContaining({ + leafEntryId: "off-path-entry", + headline: "inactive", + messageCount: 1, + active: false, + }), + ], + }, + undefined, + ); + + const switched = await invoke("sessions.branches.switch", "off-path-entry"); + expect(switched).toHaveBeenCalledWith(true, {}, undefined); + expect(mocks.queueClear).toHaveBeenCalledOnce(); + }); + + it.each([ + ["missing", "branch entry not found"], + ["user-entry", "entry is not a branch tip"], + ["assistant-entry", "branch is already active"], + ])("rejects invalid branch switch target %s", async (entryId, message) => { + const respond = await invoke("sessions.branches.switch", entryId); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.INVALID_REQUEST, + message: expect.stringContaining(message), + }), + ); + }); + it("returns editor text for rewind and a new key for fork", async () => { const fork = await invoke("sessions.fork", "user-entry"); expect(fork).toHaveBeenCalledWith( @@ -150,16 +207,19 @@ describe("session message-cut methods", () => { it("rejects externally owned conversations", async () => { mocks.external = true; - const respond = await invoke("sessions.rewind", "user-entry"); + const respond = await invoke("sessions.branches.switch", "off-path-entry"); + const listed = await invoke("sessions.branches.list"); - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: ErrorCodes.INVALID_REQUEST, - message: expect.stringContaining("external agent harness"), - }), - ); + for (const response of [respond, listed]) { + expect(response).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.INVALID_REQUEST, + message: expect.stringContaining("external agent harness"), + }), + ); + } }); it("returns a typed error for unsupported transcript storage", async () => { @@ -170,6 +230,7 @@ describe("session message-cut methods", () => { }, ); const respond = await invoke("sessions.rewind", "user-entry"); + const listed = await invoke("sessions.branches.list"); expect(respond).toHaveBeenCalledWith( false, @@ -179,14 +240,26 @@ describe("session message-cut methods", () => { message: expect.stringContaining("storage does not support rewind"), }), ); + expect(listed).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.INVALID_REQUEST, + message: expect.stringContaining("storage does not support branch listing"), + }), + ); }); it.each([ ["sessions.fork", "Fork"], ["sessions.rewind", "Rewind"], + ["sessions.branches.switch", "Branch switch"], ] as const)("rejects %s while the source run is active", async (method, label) => { mocks.active = true; - const respond = await invoke(method, "user-entry"); + const respond = await invoke( + method, + method === "sessions.branches.switch" ? "off-path-entry" : "user-entry", + ); expect(respond).toHaveBeenCalledWith( false, diff --git a/src/gateway/server-methods/sessions-rewind.ts b/src/gateway/server-methods/sessions-rewind.ts index 1b8459062111..145e84dabca0 100644 --- a/src/gateway/server-methods/sessions-rewind.ts +++ b/src/gateway/server-methods/sessions-rewind.ts @@ -1,6 +1,8 @@ import { ErrorCodes, errorShape, + validateSessionsBranchesListParams, + validateSessionsBranchesSwitchParams, validateSessionsForkParams, validateSessionsRewindParams, } from "../../../packages/gateway-protocol/src/index.js"; @@ -8,7 +10,11 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js"; import { forkSessionAtMessage, + listSessionBranches, rewindSessionToMessage, + switchSessionBranch, + type SessionBranchListResult, + type SessionBranchSwitchMutationResult, type SessionMessageCutMutationResult, } from "../../config/sessions/session-accessor.js"; import { @@ -32,12 +38,38 @@ import { import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; -type MessageCutAction = "fork" | "rewind"; +type MessageCutAction = "fork" | "rewind" | "switch"; const EXTERNAL_CONVERSATION_ERROR = - "Rewind and fork are unavailable because this session is owned by an external agent harness."; + "Session history changes are unavailable because this session is owned by an external agent harness."; export const sessionRewindHandlers: GatewayRequestHandlers = { + "sessions.branches.list": async (options) => { + if ( + !assertValidParams( + options.params, + validateSessionsBranchesListParams, + "sessions.branches.list", + options.respond, + ) + ) { + return; + } + await listBranches(options); + }, + "sessions.branches.switch": async (options) => { + if ( + !assertValidParams( + options.params, + validateSessionsBranchesSwitchParams, + "sessions.branches.switch", + options.respond, + ) + ) { + return; + } + await mutateSessionAtMessage(options, "switch"); + }, "sessions.rewind": async (options) => { if ( !assertValidParams( @@ -66,13 +98,63 @@ export const sessionRewindHandlers: GatewayRequestHandlers = { }, }; +async function listBranches(options: GatewayRequestHandlerOptions): Promise { + const { params, respond, context } = options; + const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : ""; + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId( + cfg, + sessionKey, + typeof params.agentId === "string" ? params.agentId : undefined, + ); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const current = loadAccessorSessionEntryForGatewayTarget({ + key: sessionKey, + cfg, + agentId: requestedAgent.agentId, + }); + if (!current.entry?.sessionId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${sessionKey}`), + ); + return; + } + if (readSessionUpstreamLink(current.canonicalKey, current.target.agentId)) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, EXTERNAL_CONVERSATION_ERROR)); + return; + } + const result = await listSessionBranches({ + agentId: current.target.agentId, + sessionKey: current.canonicalKey, + sessionStoreKey: current.sessionStoreKey, + storePath: current.storePath, + }); + if (result.status !== "ok") { + respondBranchListError(result, respond); + return; + } + respond(true, { branches: result.branches }, undefined); +} + async function mutateSessionAtMessage( options: GatewayRequestHandlerOptions, action: MessageCutAction, ): Promise { const { params, respond, context, client, isWebchatConnect } = options; const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : ""; - const entryId = typeof params.entryId === "string" ? params.entryId.trim() : ""; + const entryId = + action === "switch" + ? typeof params.leafEntryId === "string" + ? params.leafEntryId.trim() + : "" + : typeof params.entryId === "string" + ? params.entryId.trim() + : ""; if ( rejectWebchatSessionMutation({ action, @@ -147,8 +229,8 @@ async function mutateSessionAtMessage( if (!targetStillCurrent) { return; } - // Fork cannot disturb its source, and rewind must not invalidate queued work on failure. - // Reject live work before either transcript mutation instead of interrupting it. + // A message cut cannot disturb its source or invalidate queued work on failure. + // Reject live work before transcript mutation instead of interrupting it. blockedByActiveRun = isCompetingSessionWorkAdmissionActive(initial.storePath, lifecycleIdentities) || (asWorkerInferenceControl(context.workerEnvironmentService)?.hasInferenceForSession( @@ -179,7 +261,9 @@ async function mutateSessionAtMessage( undefined, errorShape( ErrorCodes.UNAVAILABLE, - `${action === "fork" ? "Fork" : "Rewind"} is unavailable while the agent is working.`, + action === "switch" + ? "Branch switch is unavailable while the agent is working." + : `${action === "fork" ? "Fork" : "Rewind"} is unavailable while the agent is working.`, ), ); return; @@ -229,18 +313,26 @@ async function mutateSessionAtMessage( storePath: current.storePath, targetKey, }) - : rewindSessionToMessage({ - agentId: current.target.agentId, - entryId, - sessionKey: current.canonicalKey, - sessionStoreKey: current.sessionStoreKey, - storePath: current.storePath, - })); + : action === "rewind" + ? rewindSessionToMessage({ + agentId: current.target.agentId, + entryId, + sessionKey: current.canonicalKey, + sessionStoreKey: current.sessionStoreKey, + storePath: current.storePath, + }) + : switchSessionBranch({ + agentId: current.target.agentId, + leafEntryId: entryId, + sessionKey: current.canonicalKey, + sessionStoreKey: current.sessionStoreKey, + storePath: current.storePath, + })); if (result.status !== "created") { respondMessageCutError(result, action, entryId, respond); return; } - if (action === "rewind") { + if (action !== "fork") { clearSessionQueues(lifecycleIdentities); } respond( @@ -248,9 +340,11 @@ async function mutateSessionAtMessage( action === "fork" ? { sessionKey: result.key, - ...(result.editorText ? { editorText: result.editorText } : {}), + ...("editorText" in result && result.editorText + ? { editorText: result.editorText } + : {}), } - : result.editorText + : action === "rewind" && "editorText" in result && result.editorText ? { editorText: result.editorText } : {}, undefined, @@ -261,30 +355,58 @@ async function mutateSessionAtMessage( requestedAgent.agentId ? { agentId: requestedAgent.agentId } : {}), - reason: action, + reason: action === "switch" ? "branch-switch" : action, }); }, }); } function respondMessageCutError( - result: Exclude, + result: Exclude< + SessionMessageCutMutationResult | SessionBranchSwitchMutationResult, + { status: "created" } + >, action: MessageCutAction, entryId: string, respond: GatewayRequestHandlerOptions["respond"], ): void { + const actionLabel = action === "switch" ? "branch switch" : action; const message = result.status === "missing-session" ? "session not found" : result.status === "missing-entry" - ? `message entry not found: ${entryId}` - : result.status === "not-user-message" - ? `entry is not a user message: ${entryId}` - : result.status === "off-active-path" - ? `message entry is not on the active path: ${entryId}` - : result.status === "unsupported-storage" - ? `session transcript storage does not support ${action}` - : `failed to ${action} session`; + ? `${action === "switch" ? "branch" : "message"} entry not found: ${entryId}` + : result.status === "not-branch-tip" + ? `entry is not a branch tip: ${entryId}` + : result.status === "already-active" + ? `branch is already active: ${entryId}` + : result.status === "not-user-message" + ? `entry is not a user message: ${entryId}` + : result.status === "off-active-path" + ? `message entry is not on the active path: ${entryId}` + : result.status === "unsupported-storage" + ? `session transcript storage does not support ${actionLabel}` + : `failed to ${actionLabel} session`; + respond( + false, + undefined, + errorShape( + result.status === "failed" ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST, + message, + ), + ); +} + +function respondBranchListError( + result: Exclude, + respond: GatewayRequestHandlerOptions["respond"], +): void { + const message = + result.status === "missing-session" + ? "session not found" + : result.status === "unsupported-storage" + ? "session transcript storage does not support branch listing" + : "failed to list session branches"; respond( false, undefined, diff --git a/src/gateway/server-methods/sessions-shared.ts b/src/gateway/server-methods/sessions-shared.ts index e3e9eaa97d3c..6c6d0b498e64 100644 --- a/src/gateway/server-methods/sessions-shared.ts +++ b/src/gateway/server-methods/sessions-shared.ts @@ -31,7 +31,7 @@ export const sessionLog = createSubsystemLogger("gateway/sessions"); export class SessionWorkerPlacementMutationError extends Error { constructor( readonly placementState: SessionPlacement["state"], - action: "delete" | "fork" | "reset" | "restore" | "rewind", + action: "delete" | "fork" | "reset" | "restore" | "rewind" | "switch", key: string, ) { super(`Session ${key} cannot ${action} while cloud worker placement is ${placementState}.`); @@ -39,7 +39,7 @@ export class SessionWorkerPlacementMutationError extends Error { } export function resolveSessionWorkerPlacementMutationError(params: { - action: "delete" | "fork" | "reset" | "restore" | "rewind"; + action: "delete" | "fork" | "reset" | "restore" | "rewind" | "switch"; context: GatewayRequestContext; key: string; sessionId: string | undefined; @@ -286,6 +286,7 @@ export function rejectWebchatSessionMutation(params: { | "restore" | "rewind" | "fork" + | "switch" | "dispatch" | "reclaim"; client: GatewayClient | null; diff --git a/src/plugins/registry-runtime.ts b/src/plugins/registry-runtime.ts index 72d505866f60..5372ace3c046 100644 --- a/src/plugins/registry-runtime.ts +++ b/src/plugins/registry-runtime.ts @@ -44,6 +44,7 @@ const PLUGIN_GATEWAY_SESSION_MUTATION_METHODS = new Set([ "sessions.compact", "sessions.compaction.branch", "sessions.compaction.restore", + "sessions.branches.switch", "sessions.rewind", "sessions.fork", "sessions.create", diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 8f5bbfbadd69..95eeead63c21 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -584,6 +584,11 @@ export type SessionsRewindResult = import("../../../packages/gateway-protocol/src/index.js").SessionsRewindResult; export type SessionsForkResult = import("../../../packages/gateway-protocol/src/index.js").SessionsForkResult; +export type SessionBranch = import("../../../packages/gateway-protocol/src/index.js").SessionBranch; +export type SessionsBranchesListResult = + import("../../../packages/gateway-protocol/src/index.js").SessionsBranchesListResult; +export type SessionsBranchesSwitchResult = + import("../../../packages/gateway-protocol/src/index.js").SessionsBranchesSwitchResult; export type SessionsPatchResult = SessionsPatchResultBase<{ sessionId: string; diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 5b0d5927f851..89f9231eb2bf 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -3513,6 +3513,13 @@ export const en: TranslationMap = { copyPath: "Copy path", copyBranch: "Copy branch name", copied: "Copied", + branches: "Session branches", + branchSwitchUnavailable: "Branch switch is unavailable while the agent is working.", + branchSwitchRequiresAdmin: "Branch switching requires operator admin access.", + untitledBranch: "Untitled branch", + oneMessage: "{count} message", + messages: "{count} messages", + activeBranch: "Active branch", }, board: { faceLabel: "Session face", diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts index f35c38c2a871..d2abded57e5b 100644 --- a/ui/src/lib/sessions/index.ts +++ b/ui/src/lib/sessions/index.ts @@ -6,12 +6,15 @@ import { } from "../../api/gateway.ts"; import type { GatewaySessionRow, + SessionBranch, SessionCompactionCheckpoint, SessionRunStatus, SessionsCompactionBranchResult, SessionsCompactionListResult, SessionsCompactionRestoreResult, SessionsForkResult, + SessionsBranchesListResult, + SessionsBranchesSwitchResult, SessionsListResult, SessionsPatchResult, SessionsRewindResult, @@ -241,6 +244,12 @@ export type SessionCapability = { entryId: string, options?: { agentId?: string | null }, ) => Promise; + listBranches: (key: string, options?: { agentId?: string | null }) => Promise; + switchBranch: ( + key: string, + leafEntryId: string, + options?: { agentId?: string | null }, + ) => Promise; /** Loads the gateway-owned group catalog, coalescing successful connection attempts. */ groupsLoad: () => Promise; /** Replaces the group catalog; stale means the initiating connection retired. */ @@ -293,6 +302,18 @@ function buildSessionRequestParams( }; } +function buildTranscriptMutationParams( + sessionKey: string, + agentId?: string | null, +): { sessionKey: string; agentId?: string } { + const normalizedSessionKey = sessionKey.trim(); + const normalizedAgentId = agentId?.trim(); + return { + sessionKey: normalizedSessionKey, + ...(normalizedAgentId ? { agentId: normalizedAgentId } : {}), + }; +} + function buildSessionListParams(options: SessionListOptions = {}): Record { const params: Record = { ...SESSION_LIST_PARAMS, @@ -537,7 +558,7 @@ function rewindSessionAtMessage( options: { agentId?: string | null } = {}, ): Promise { return client.request("sessions.rewind", { - ...buildSessionRequestParams(key, options.agentId), + ...buildTranscriptMutationParams(key, options.agentId), entryId, }); } @@ -549,11 +570,34 @@ function forkSessionAtMessage( options: { agentId?: string | null } = {}, ): Promise { return client.request("sessions.fork", { - ...buildSessionRequestParams(key, options.agentId), + ...buildTranscriptMutationParams(key, options.agentId), entryId, }); } +function listSessionBranches( + client: SessionRequestClient, + key: string, + options: { agentId?: string | null } = {}, +): Promise { + return client.request( + "sessions.branches.list", + buildTranscriptMutationParams(key, options.agentId), + ); +} + +function switchSessionBranch( + client: SessionRequestClient, + key: string, + leafEntryId: string, + options: { agentId?: string | null } = {}, +): Promise { + return client.request("sessions.branches.switch", { + ...buildTranscriptMutationParams(key, options.agentId), + leafEntryId, + }); +} + function appendSessionResults( previous: SessionsListResult, page: SessionsListResult, @@ -1491,6 +1535,34 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil return result; }; + const listBranches = async ( + key: string, + options: { agentId?: string | null } = {}, + ): Promise => { + const scope = captureConnection(); + if (!scope) { + return []; + } + const result = await listSessionBranches(scope.client, key, options); + return isCurrentConnection(scope) ? result.branches : []; + }; + + const switchBranch = async ( + key: string, + leafEntryId: string, + options: { agentId?: string | null } = {}, + ): Promise => { + const scope = captureConnection(); + if (!scope) { + throw new Error("Session branch switch requires an active Gateway connection"); + } + const result = await switchSessionBranch(scope.client, key, leafEntryId, options); + if (isCurrentConnection(scope)) { + await refreshReplacement(options.agentId ?? state.agentId ?? undefined).catch(() => {}); + } + return result; + }; + const stopGateway = gateway.subscribe((next) => { const connectionChanged = next.client !== connectionClient || next.connected !== connectionConnected; @@ -1615,6 +1687,8 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil restoreCheckpoint, rewind, forkAtMessage, + listBranches, + switchBranch, groupsLoad, groupsPut, groupsRename, diff --git a/ui/src/lib/sessions/message-cut.test.ts b/ui/src/lib/sessions/message-cut.test.ts index 1bfbba110982..1a754385f6a0 100644 --- a/ui/src/lib/sessions/message-cut.test.ts +++ b/ui/src/lib/sessions/message-cut.test.ts @@ -67,6 +67,10 @@ describe("session capability message cuts", () => { committed.resolve({ editorText: "edit me" }); await expect(pending).resolves.toEqual({ editorText: "edit me" }); + expect(request).toHaveBeenCalledWith("sessions.rewind", { + sessionKey: "agent:main:main", + entryId: "user-entry", + }); sessions.dispose(); }); @@ -88,6 +92,42 @@ describe("session capability message cuts", () => { sessionKey: "agent:main:dashboard:forked", editorText: "edit me", }); + expect(request).toHaveBeenCalledWith("sessions.fork", { + sessionKey: "agent:main:main", + entryId: "user-entry", + }); + sessions.dispose(); + }); + + it("lists branches and sends the selected leaf to the switch RPC", async () => { + const branch = { + leafEntryId: "branch-b", + headline: "Try the earlier path", + messageCount: 3, + active: false, + }; + const request = vi.fn(async (method: string) => { + if (method === "sessions.branches.list") { + return { branches: [branch] }; + } + if (method === "sessions.branches.switch") { + return {}; + } + if (method === "sessions.list") { + return { sessions: [], count: 0 }; + } + throw new Error(`Unexpected request: ${method}`); + }); + const client = { request } as unknown as GatewayBrowserClient; + const { gateway } = createGatewayHarness(client); + const sessions = createSessionCapability(gateway); + + await expect(sessions.listBranches("agent:main:main")).resolves.toEqual([branch]); + await expect(sessions.switchBranch("agent:main:main", "branch-b")).resolves.toEqual({}); + expect(request).toHaveBeenCalledWith("sessions.branches.switch", { + sessionKey: "agent:main:main", + leafEntryId: "branch-b", + }); sessions.dispose(); }); }); diff --git a/ui/src/pages/chat/chat-history.test.ts b/ui/src/pages/chat/chat-history.test.ts index bf3125ca3c0e..81c066744a96 100644 --- a/ui/src/pages/chat/chat-history.test.ts +++ b/ui/src/pages/chat/chat-history.test.ts @@ -4,6 +4,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { loadChatHistory, rewindChatHistory, + switchChatHistoryBranch, type ChatHistoryResult, type ChatState, } from "./chat-history.ts"; @@ -168,6 +169,74 @@ describe("rewindChatHistory", () => { }); }); +describe("switchChatHistoryBranch", () => { + it("clears the cached snapshot and refetches history plus branches", async () => { + const state = createState({ + messages: [{ role: "assistant", content: "restored branch" }], + }) as TestState & { + chatMessagesBySession: ChatMessageCache; + sessions: { + listBranches: ReturnType; + switchBranch: ReturnType; + }; + }; + state.sessionKey = "agent:main:branches"; + state.chatMessages = [{ role: "assistant", content: "stale branch" }]; + state.chatMessagesBySession = new Map(); + state.sessions = { + listBranches: vi.fn().mockResolvedValue([ + { + leafEntryId: "branch-b", + headline: "restored branch", + messageCount: 2, + active: true, + }, + ]), + switchBranch: vi.fn().mockResolvedValue({}), + setModelOverride: vi.fn(), + }; + cacheChatSessionSnapshot( + state.chatMessagesBySession, + state, + { sessionKey: state.sessionKey }, + { + messages: state.chatMessages, + pagination: { hasMore: false, completeSnapshot: true }, + sessionId: "old-session", + }, + ); + + await expect(switchChatHistoryBranch(state as never, "branch-b")).resolves.toBe(true); + + expect(state.sessions.switchBranch).toHaveBeenCalledWith( + state.sessionKey, + "branch-b", + expect.any(Object), + ); + expect(state.sessions.listBranches).toHaveBeenCalledWith(state.sessionKey, expect.any(Object)); + expect(state.chatMessages).toEqual([{ role: "assistant", content: "restored branch" }]); + expect( + readChatMessagesFromCache(state.chatMessagesBySession, state, { + sessionKey: state.sessionKey, + }), + ).toEqual([{ role: "assistant", content: "restored branch" }]); + }); + + it("refreshes branch metadata after the Gateway connection changes", async () => { + const state = createState({ messages: [] }) as TestState & { + sessions: { listBranches: ReturnType }; + }; + state.chatBranchesSessionKey = state.sessionKey; + state.chatBranchesConnectionEpoch = state.connectionEpoch - 1; + state.sessions = { listBranches: vi.fn().mockResolvedValue([]), setModelOverride: vi.fn() }; + + await loadChatHistory(state); + + expect(state.sessions.listBranches).toHaveBeenCalledWith(state.sessionKey, expect.any(Object)); + expect(state.chatBranchesConnectionEpoch).toBe(state.connectionEpoch); + }); +}); + describe("chat history plan replay", () => { const retainedPlan = { runId: "run-retained", diff --git a/ui/src/pages/chat/chat-history.ts b/ui/src/pages/chat/chat-history.ts index 40a92f05b93f..5ff88d9e8a0e 100644 --- a/ui/src/pages/chat/chat-history.ts +++ b/ui/src/pages/chat/chat-history.ts @@ -6,6 +6,7 @@ import type { GatewaySessionRow, GatewaySessionsDefaults, ModelCatalogEntry, + SessionBranch, SessionsListResult, } from "../../api/types.ts"; import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts"; @@ -90,6 +91,7 @@ const SYNTHETIC_TRANSCRIPT_REPAIR_RESULT = const CHAT_HISTORY_REQUEST_LIMIT = 100; const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 60_000; const chatHistoryRequestVersions = new WeakMap(); +const chatBranchRequestVersions = new WeakMap(); const selectedSessionMessageSubscriptionGenerations = new WeakMap(); type ChatHistoryRequestOwnership = { @@ -354,6 +356,12 @@ export type ChatState = { agentsSelectedId?: string | null; hello: GatewayHelloOk | null; settings?: { chatPersistCommentary?: boolean; gatewayUrl?: string | null }; + sessions?: Partial; + chatBranches?: SessionBranch[]; + chatBranchesSessionKey?: string | null; + chatBranchesConnectionEpoch?: number | null; + chatBranchesLoading?: boolean; + requestUpdate?: () => void; }; type ChatAgentsListSnapshot = Partial> & { @@ -801,6 +809,11 @@ type RewindChatHistoryState = ChatState & sessions: Pick; }; +type SwitchChatHistoryBranchState = ChatState & + Parameters[0] & { + sessions: Pick; + }; + function hasAbortableChatSessionRun(state: ClearChatHistoryState): boolean { if (state.chatRunId) { return true; @@ -933,7 +946,7 @@ export async function rewindChatHistory( return null; } state.chatMessages = []; - await loadChatHistory(state); + await Promise.all([loadChatHistory(state), loadChatBranches(state)]); if (!visibleSessionMatches(state, sessionKey, agentParams.agentId)) { return null; } @@ -946,6 +959,87 @@ export async function rewindChatHistory( } } +export async function switchChatHistoryBranch( + state: SwitchChatHistoryBranchState, + leafEntryId: string, +): Promise { + if (!state.client || !state.connected) { + return false; + } + const sessionKey = state.sessionKey; + const agentParams = scopedAgentParamsForSession(state, sessionKey); + try { + await state.sessions.switchBranch(sessionKey, leafEntryId, agentParams); + if (state.chatMessagesBySession) { + clearChatMessagesFromCache(state.chatMessagesBySession, state, { + sessionKey, + agentId: agentParams.agentId, + }); + } + if (!visibleSessionMatches(state, sessionKey, agentParams.agentId)) { + return false; + } + state.chatMessages = []; + await Promise.all([loadChatHistory(state), loadChatBranches(state)]); + return visibleSessionMatches(state, sessionKey, agentParams.agentId); + } catch (error) { + setChatError(state, error instanceof Error ? error.message : String(error)); + scheduleChatScroll(state); + return false; + } +} + +export async function loadChatBranches(state: ChatState): Promise { + const sessions = state.sessions; + const client = state.client; + const sessionKey = state.sessionKey; + if (!sessions?.listBranches || !client || !state.connected) { + return; + } + if (isGatewayMethodAdvertised(state, "sessions.branches.list") === false) { + state.chatBranches = []; + state.chatBranchesSessionKey = sessionKey; + state.chatBranchesConnectionEpoch = state.connectionEpoch; + return; + } + const version = (chatBranchRequestVersions.get(state as object) ?? 0) + 1; + chatBranchRequestVersions.set(state as object, version); + const connectionEpoch = state.connectionEpoch; + const agentParams = scopedAgentParamsForSession(state, sessionKey); + state.chatBranchesLoading = true; + try { + const branches = await sessions.listBranches(sessionKey, agentParams); + if ( + chatBranchRequestVersions.get(state as object) !== version || + state.client !== client || + !state.connected || + state.connectionEpoch !== connectionEpoch || + !visibleSessionMatches(state, sessionKey, agentParams.agentId) + ) { + return; + } + state.chatBranches = branches; + state.chatBranchesSessionKey = sessionKey; + state.chatBranchesConnectionEpoch = connectionEpoch; + } catch { + if ( + chatBranchRequestVersions.get(state as object) === version && + state.client === client && + state.connectionEpoch === connectionEpoch && + visibleSessionMatches(state, sessionKey, agentParams.agentId) + ) { + state.chatBranches = []; + state.chatBranchesSessionKey = sessionKey; + state.chatBranchesConnectionEpoch = connectionEpoch; + } + } finally { + if (chatBranchRequestVersions.get(state as object) === version) { + state.chatBranchesLoading = false; + state.requestUpdate?.(); + } + } +} + export async function loadChatHistory( state: ChatState, opts: LoadChatHistoryOptions = {}, @@ -972,6 +1066,12 @@ export async function loadChatHistory( ) { return inFlight.promise; } + if ( + state.chatBranchesSessionKey !== sessionKey || + state.chatBranchesConnectionEpoch !== connectionEpoch + ) { + void loadChatBranches(state); + } const promise = loadChatHistoryUncached( state, client, diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index 030a84bd0671..324c0825d511 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -118,6 +118,7 @@ import { loadOlderChatHistoryPage, rewindChatHistory, resolveChatHistoryPagination, + switchChatHistoryBranch, syncSelectedSessionMessageSubscription, } from "./chat-history.ts"; import { @@ -164,6 +165,7 @@ import { renderBackgroundTasksToggle, type BackgroundTasksProps, } from "./components/chat-background-tasks.ts"; +import { isChatRunWorking } from "./components/chat-composer.ts"; import { renderChatControls } from "./components/chat-controls.ts"; import { canRevealSessionWorkspace, @@ -1436,6 +1438,15 @@ class ChatPane extends OpenClawLightDomElement { } } + private async switchToBranch(leafEntryId: string): Promise { + const state = this.state; + if (!state) { + return; + } + await switchChatHistoryBranch(state, leafEntryId); + state.requestUpdate?.(); + } + private readonly handleCommandPaletteSlashCommand = (command: string) => { const state = this.state; if (!state) { @@ -2630,6 +2641,23 @@ class ChatPane extends OpenClawLightDomElement { isGatewayMethodAdvertised(this.context.gateway.snapshot, "sessions.files.reveal") === true, hasAdminAccess: hasOperatorAdminAccess(this.context.gateway.snapshot.hello?.auth ?? null), }); + const branchSwitchWorking = this.state + ? this.state.chatSending || + isChatRunWorking({ + canAbort: hasAbortableSessionRun(this.state), + onAbort: () => undefined, + queue: this.state.chatQueue, + runStatus: this.state.chatRunStatus, + sessionKey: this.state.sessionKey, + }) + : false; + const branchSwitchDisabledReason = !hasOperatorAdminAccess( + this.context.gateway.snapshot.hello?.auth ?? null, + ) + ? t("chat.sessionHeader.branchSwitchRequiresAdmin") + : branchSwitchWorking + ? t("chat.sessionHeader.branchSwitchUnavailable") + : null; return renderChatPaneHeader({ paneId: this.paneId, narrow: this.narrow, @@ -2642,6 +2670,11 @@ class ChatPane extends OpenClawLightDomElement { workspaceRoot: workspace.root, workspaceLabel: workspace.label, branch, + branches: + this.state && this.state.chatBranchesSessionKey === this.state.sessionKey + ? (this.state.chatBranches ?? []) + : [], + branchSwitchDisabledReason, platform: this.headerPlatform, canReveal, copiedAction: this.headerCopiedAction, @@ -2674,6 +2707,7 @@ class ChatPane extends OpenClawLightDomElement { this.handleHeaderMenuAction(action, row, workspace.root, branch); } }, + onBranchSelect: (leafEntryId) => void this.switchToBranch(leafEntryId), onOpenSplitView: this.onOpenSplitView, onSplitDown: this.onSplitDown, onSplitRight: this.onSplitRight, diff --git a/ui/src/pages/chat/chat-state.ts b/ui/src/pages/chat/chat-state.ts index 4ecf7953fd88..e7dbc37e0976 100644 --- a/ui/src/pages/chat/chat-state.ts +++ b/ui/src/pages/chat/chat-state.ts @@ -58,6 +58,7 @@ import { import { chatScopedEventSessionMatches, isHiddenAssistantStreamText, + loadChatBranches, loadChatHistory, shouldHideAssistantChatMessage, type ChatMetadataResult, @@ -517,6 +518,10 @@ export function resetChatStateForRouteSession( state.chatAttachments = []; state.chatReplyTarget = null; state.chatMessages = snapshot.messages; + state.chatBranches = []; + state.chatBranchesSessionKey = null; + state.chatBranchesConnectionEpoch = null; + state.chatBranchesLoading = false; state.chatToolMessages = []; state.chatStreamSegments = []; state.chatThinkingLevel = null; @@ -1077,6 +1082,9 @@ function handleSessionMessageEvent(state: ChatPageHost, payload: unknown) { return; } const matchesChat = sessionMessageMatchesChat(state, event); + if (matchesChat) { + void loadChatBranches(state); + } if (matchesChat && event.archived !== null) { state.selectedChatSessionArchived = event.archived; } @@ -1138,12 +1146,13 @@ function replayPendingSessionMessageReload( function handleSessionsChangedEvent(state: ChatPageHost, payload: unknown) { const runIdBeforeApply = state.chatRunId; const event = readSessionChangedEvent(payload); - if ( - event && - globalSessionEventMatchesChat(state, event) && - sessionMessageMatchesChat(state, event) && - event.archived !== null - ) { + const matchesChat = Boolean( + event && globalSessionEventMatchesChat(state, event) && sessionMessageMatchesChat(state, event), + ); + if (matchesChat) { + void loadChatBranches(state); + } + if (event && matchesChat && event.archived !== null) { state.selectedChatSessionArchived = event.archived; } const result = reconcileSessionEvent(state, payload); @@ -1151,7 +1160,7 @@ function handleSessionsChangedEvent(state: ChatPageHost, payload: unknown) { result.applied && event && runIdBeforeApply && - sessionMessageMatchesChat(state, event) && + matchesChat && finishSessionMessageRunReconcile( state, event.key, @@ -1239,6 +1248,10 @@ export function createPageState( chatSending: false, chatMessage: "", chatMessages: [] as unknown[], + chatBranches: [], + chatBranchesSessionKey: null, + chatBranchesConnectionEpoch: null, + chatBranchesLoading: false, chatToolMessages: [] as Record[], chatThinkingLevel: null, chatVerboseLevel: null, diff --git a/ui/src/pages/chat/components/chat-pane-header.test.ts b/ui/src/pages/chat/components/chat-pane-header.test.ts index 8ab8962a2b20..e5aa77c4c399 100644 --- a/ui/src/pages/chat/components/chat-pane-header.test.ts +++ b/ui/src/pages/chat/components/chat-pane-header.test.ts @@ -42,6 +42,8 @@ function mount(patch: Partial = {}) { workspaceRoot: "/repo/openclaw", workspaceLabel: "openclaw", branch: "feature/header", + branches: [], + branchSwitchDisabledReason: null, platform: "darwin", canReveal: true, copiedAction: null, @@ -56,6 +58,7 @@ function mount(patch: Partial = {}) { onCancelRename: vi.fn(), onMenuOpenChange: vi.fn(), onMenuAction: vi.fn(), + onBranchSelect: vi.fn(), ...patch, }; render(html`${renderChatPaneHeader(props)}`, container); @@ -163,6 +166,58 @@ describe("chat pane header", () => { expect(container.querySelector('wa-dropdown-item[value="reveal"]')).toBeNull(); expect(container.querySelector('wa-dropdown-item[value="copy-path"]')).not.toBeNull(); }); + + it("hides one branch and lists multiple branches with the active tip marked", () => { + const one = mount({ + branches: [{ leafEntryId: "only", headline: "Only path", messageCount: 1, active: true }], + }); + expect(one.container.querySelector(".chat-pane__branches-trigger")).toBeNull(); + + const multiple = mount({ + branches: [ + { leafEntryId: "active", headline: "Current work", messageCount: 4, active: true }, + { + leafEntryId: "other", + headline: "Earlier idea", + messageCount: 2, + updatedAt: new Date(Date.now() - 60_000).toISOString(), + active: false, + }, + ], + }); + const items = multiple.container.querySelectorAll(".chat-pane__branch-item"); + expect(multiple.container.querySelector(".chat-pane__branches-trigger")).not.toBeNull(); + expect(items).toHaveLength(2); + expect(items[0]?.textContent).toContain("Current work"); + expect(items[0]?.getAttribute("data-active")).toBe("true"); + expect(items[0]?.querySelector(".chat-pane__branch-active")).not.toBeNull(); + expect(items[1]?.textContent).toContain("Earlier idea"); + + multiple.container.querySelector(".chat-pane__branches-menu")?.dispatchEvent( + new CustomEvent("wa-select", { + detail: { item: { value: "other" } }, + }), + ); + expect(multiple.props.onBranchSelect).toHaveBeenCalledWith("other"); + }); + + it("disables branch switching while the agent is working", () => { + const { container, props } = mount({ + branchSwitchDisabledReason: "Branch switch is unavailable while the agent is working.", + branches: [ + { leafEntryId: "active", headline: "Current work", messageCount: 4, active: true }, + { leafEntryId: "other", headline: "Earlier idea", messageCount: 2, active: false }, + ], + }); + const trigger = container.querySelector(".chat-pane__branches-trigger"); + expect(trigger?.disabled).toBe(true); + container.querySelector(".chat-pane__branches-menu")?.dispatchEvent( + new CustomEvent("wa-select", { + detail: { item: { value: "other" } }, + }), + ); + expect(props.onBranchSelect).not.toHaveBeenCalled(); + }); }); describe("chat pane workspace resolution", () => { diff --git a/ui/src/pages/chat/components/chat-pane-header.ts b/ui/src/pages/chat/components/chat-pane-header.ts index 349269e17e04..ec9abfcac3e4 100644 --- a/ui/src/pages/chat/components/chat-pane-header.ts +++ b/ui/src/pages/chat/components/chat-pane-header.ts @@ -1,5 +1,5 @@ import { html, nothing, type TemplateResult } from "lit"; -import type { GatewaySessionRow } from "../../../api/types.ts"; +import type { GatewaySessionRow, SessionBranch } from "../../../api/types.ts"; import { beginNativeWindowDrag } from "../../../app/native-window-drag.ts"; import { COMMAND_PALETTE_OPEN_EVENT, @@ -11,6 +11,7 @@ import { isCloudWorkerPlacementState } from "../../../components/session-row-bad import "../../../components/tooltip.ts"; import "../../../components/web-awesome.ts"; import { t } from "../../../i18n/index.ts"; +import { formatRelativeTimestamp } from "../../../lib/format.ts"; export type ChatPaneHeaderAction = "reveal" | "copy-path" | "copy-branch"; @@ -26,6 +27,8 @@ type ChatPaneHeaderProps = { workspaceRoot: string | null; workspaceLabel: string | null; branch: string | null; + branches: SessionBranch[]; + branchSwitchDisabledReason: string | null; platform: string | null; canReveal: boolean; copiedAction: ChatPaneHeaderAction | null; @@ -42,6 +45,7 @@ type ChatPaneHeaderProps = { onCancelRename: () => void; onMenuOpenChange: (open: boolean) => void; onMenuAction: (action: ChatPaneHeaderAction) => void; + onBranchSelect: (leafEntryId: string) => void; onOpenSplitView?: () => void; onSplitDown?: (paneId: string) => void; onSplitRight?: (paneId: string) => void; @@ -63,6 +67,11 @@ function pathBasename(value: string): string { return trimmed.split(/[\\/]/).pop() || trimmed; } +function branchRelativeTime(updatedAt: string | undefined): string { + const timestamp = updatedAt ? Date.parse(updatedAt) : Number.NaN; + return Number.isFinite(timestamp) ? formatRelativeTimestamp(timestamp, { fallback: "" }) : ""; +} + export function resolveChatPaneWorkspace(params: { session: GatewaySessionRow | undefined; agentWorkspace?: string; @@ -224,6 +233,69 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) { ` : nothing} ${props.faceControl ?? nothing} + ${!props.catalog && props.branches.length > 1 + ? html` + ) => { + const leafEntryId = event.detail.item.value; + const branch = props.branches.find( + (candidate) => candidate.leafEntryId === leafEntryId, + ); + if (leafEntryId && branch && !branch.active && !props.branchSwitchDisabledReason) { + props.onBranchSelect(leafEntryId); + } + }} + > + + + + ${props.branches.map((branch) => { + const relativeTime = branchRelativeTime(branch.updatedAt); + return html` + + + ${branch.headline || t("chat.sessionHeader.untitledBranch")} + ${t( + branch.messageCount === 1 + ? "chat.sessionHeader.oneMessage" + : "chat.sessionHeader.messages", + { count: String(branch.messageCount) }, + )}${relativeTime ? ` · ${relativeTime}` : ""} + + ${branch.active + ? html`${icons.check}` + : nothing} + + `; + })} + + ` + : nothing}
${props.boardDockAction ?? nothing} ${props.terminalAction} ${props.catalog diff --git a/ui/src/styles/chat/split-view.css b/ui/src/styles/chat/split-view.css index 21c3f52fd9bd..a59f0f3cb649 100644 --- a/ui/src/styles/chat/split-view.css +++ b/ui/src/styles/chat/split-view.css @@ -214,6 +214,44 @@ openclaw-chat-pane { min-width: 0; } +.chat-pane__branches-menu { + flex: 0 0 auto; +} + +.chat-pane__branches-trigger svg, +.chat-pane__branch-active svg { + width: 14px; + height: 14px; +} + +.chat-pane__branch-item { + min-width: 260px; + max-width: 360px; +} + +.chat-pane__branch-copy { + display: grid; + min-width: 0; + gap: 2px; +} + +.chat-pane__branch-headline { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-pane__branch-meta { + color: var(--muted); + font-size: 11px; +} + +.chat-pane__branch-active { + display: inline-flex; + margin-left: auto; + color: var(--accent); +} + .chat-pane__workspace-chip { display: inline-flex; max-width: 180px;