diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 4054619e80ad..ef5b91843760 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -182,6 +182,12 @@ public enum SessionDiffFileStatus: String, Codable, Sendable { case renamed = "renamed" } +public enum SessionDiffScope: String, Codable, Sendable { + case all = "all" + case uncommitted = "uncommitted" + case commit = "commit" +} + public enum TaskSuggestionResolution: String, Codable, Sendable { case dismissed = "dismissed" case accepted = "accepted" @@ -8005,21 +8011,47 @@ public struct SessionDiffFile: Codable, Sendable { } } +public struct SessionDiffCommit: Codable, Sendable { + public let sha: String + public let subject: String + + public init( + sha: String, + subject: String) + { + self.sha = sha + self.subject = subject + } + + private enum CodingKeys: String, CodingKey { + case sha + case subject + } +} + public struct SessionsDiffParams: Codable, Sendable { public let sessionkey: String public let agentid: String? + public let scope: SessionDiffScope? + public let commit: String? public init( sessionkey: String, - agentid: String? = nil) + agentid: String? = nil, + scope: SessionDiffScope? = nil, + commit: String? = nil) { self.sessionkey = sessionkey self.agentid = agentid + self.scope = scope + self.commit = commit } private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" case agentid = "agentId" + case scope + case commit } } @@ -8028,6 +8060,9 @@ public struct SessionsDiffResult: Codable, Sendable { public let root: String? public let branch: String? public let baseref: String? + public let aheadcount: Int? + public let commits: [SessionDiffCommit]? + public let mergebase: SessionDiffCommit? public let files: [SessionDiffFile] public let additions: Int public let deletions: Int @@ -8039,6 +8074,9 @@ public struct SessionsDiffResult: Codable, Sendable { root: String? = nil, branch: String? = nil, baseref: String? = nil, + aheadcount: Int? = nil, + commits: [SessionDiffCommit]? = nil, + mergebase: SessionDiffCommit? = nil, files: [SessionDiffFile], additions: Int, deletions: Int, @@ -8049,6 +8087,9 @@ public struct SessionsDiffResult: Codable, Sendable { self.root = root self.branch = branch self.baseref = baseref + self.aheadcount = aheadcount + self.commits = commits + self.mergebase = mergebase self.files = files self.additions = additions self.deletions = deletions @@ -8061,6 +8102,9 @@ public struct SessionsDiffResult: Codable, Sendable { case root case branch case baseref = "baseRef" + case aheadcount = "aheadCount" + case commits + case mergebase = "mergeBase" case files case additions case deletions diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 71e7a14df4c3..b14cb45a6a19 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"8c6ff70bbf195705355c6599c50819065f5f350f0faaddc4a868b5e86da65e28","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"40f4454b9a60030b2a1ba050abaad8993e821cb84444365728dc3d12de86e1af","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 24728a1bca39..6c5a518d1f2b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"91311c46d7fa124d5b79e0a8a6640f2ef8436c5000c1dafa3391f8c1303054b7","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"c700bd1f9821574a3d0a20815d55fc8b3857d71cdbfeab0a9bfb394bb81fe745","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json index ce3ba2b55349..7d26079d9ecf 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json @@ -1 +1 @@ -{"contentHash":"fac71b2c80db2874419a589646fd560e8a8ad43dba93f5a6d26a73c171a0b4ba","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} +{"contentHash":"4b54a0f38cb2d46ac2446e6ec7003fddbe1cbe19d8b34d28354b91cb70293f02","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index a546efe66f2f..886771e7b69f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"c3b22d889dc4c65a0ab8c4f5743e49df7f18a399a77b271a56e2cd60a2671fea","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"30538b51154ba0bdebf6bf02eefd16c73b7fd1043b074f3595db3a3088bfc98c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 4bddaffac84c..291a40c23610 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"7cc678c87c2d6063305951bc9bcd75f78f4fe2a9408a263bc1cb294c66075b50","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"0c9c99f96d0c050db645580b2bb91405485423e2d7fd14031b101d4b44026537","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json index 59084efcebe3..d1c6210f1570 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json @@ -1 +1 @@ -{"contentHash":"d6c0e5bf1e49fc6e003221a3ff6daf951082742fa7e82215572456e4d4eaaf18","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} +{"contentHash":"1934d2e58a1139d5f0fe9b69d231a9b1140a0a9f00f13c44ef36926e99d6d0cc","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 505d71483049..571e4e56af2b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"f30a3ff93dab57ce1a6e73c10b027d2bb748968cd87cda27228654fc41811a03","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"ad9dc515e2c9ed1c15397a9846c6212c56276c5dda24635be3f315312f077e43","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 251f4ea554dc..6e63230bb6da 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"b6ef4ba8c3840141bff65a7fee3a5ea8efbe3da79696f5d59b41f8ccf1e32914","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"29089a2b47826afc64a85979f3606d4140aa272a77e90247dd92e65bca3a2dd8","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 4da75b1b6b96..3c1e1c698bd6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"43afd1b1996e17f373e75c3922cbea6a0223782b0b68f6d05f7c29bbd83b8ebb","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"1eda9bc8cdf2adff5b1c04f0d9eaafed8c448bccae1728ad7a6475040e48c960","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index ceac45434780..f2423c7ed98a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"4d6021d0ae4ad15e1ce39d8700756ec415d604606ed538240c8f8aa9bf0acc37","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"ca227941dce03110d71765ad1d2e8df89e30d09c19f664082d4e2a113937211b","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index a581faedc4ea..e984996cdaba 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"17eaa6392ffdf071bbe104a7c5720892042a906109be42478dec03b8bf5291a0","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"6de4593681bd8424e7334550612c4e1e9962e1a4dda50effc6890da7a4076fe5","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index f232b4541473..8879d0effadc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"176c5db037a1e5b685c0550aeee46186b3273e8da4bbb8a720a074596598e8cc","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"225e30b4d18d4c77aee34623d9e69263835eddf690d0ad6d9dc1285e8f349d06","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 7c0976da00ab..c64cc8330183 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"7bbbe7b8509abe241992eb632b1b484df2c270534f485ecf0f80e48b5671566a","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"b156cd10587667ccf02b0fd7e066de0cf37cd219f0c07c8fb065c293e1f675de","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 94e70da01e84..65f9fa9c219a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"00ae2e13016a7ba0ebd4baef8b34a2b3811e0221799f6351d2189f6e0ea694ba","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"1e566fe360e6b82b1d31ded008160ddbfbc97713d3d55f1b9f3ba641825d537c","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 474c9c965de1..0ae521065c36 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"e20acaab6a8d3d56b57a487168439da89adfe250f0474a4c53350424415ac498","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"05d6916c82b9a5b512fd541e68a56040aee9a8a23019e6c70e8a62cdcee16716","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 0dd7bab53d6f..791f17163d87 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"31e0fb8e73f98c8f69f8808aec8d8dc18fc2a074a21b9fc82414729fa1acceee","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"0d68fc98d2c75f2dc4d74670b8d902000c7af2945b65aeae2f783a8e705675a7","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 1661414f0136..75abdbb72639 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"4918ded36f8fc0701bd94e4be965577c1c035977e6c5d32db1c17862775d6318","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"34eb7210106298e9efc89a2891438dd05e198a1b6a7510f0c948c4f916b752ca","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index bc034e25fcc5..29e0e38c38df 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"cd199186dee1148dc53d05710cb9785fdcff475bedbc92f5c18b5ddca675dbe7","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"e573cfb3d9ee7c9f79aee3f425fd804953e9df34283196006033dea6ee302396","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 92ab4d7a41d3..e154f726fa75 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"265be26af18bed1c86ddc4636df78ac2f064848dcfc9e9da8e05842c69750ba4","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"baecdbe479ff6b3a7ae95d67ce3539d61acd424cea71692a04276972884ea372","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index abd4503c2e9b..b7782a3e3eb6 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -496,7 +496,7 @@ Capability toggles stay disabled until the Gateway, session, and runtime config - 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. 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. - 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. The Gateway polls only sessions visible in a connected Control UI and pushes changed snapshots through `controlUi.sessionPullRequests.changed`; it reuses `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; once the branch's PR is merged and the pushed tip still matches the merged head, the row disappears too (returning without the Create PR button only when new local work appears, and with it once new commits are pushed past the merged head). 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`. + - The session diff panel shows what a session's checkout actually changed: the branch button in the workspace rail or chat title bar opens a dense per-file viewer with normalized added/deleted/modified counts, collapsible files, wrapping and unified/split layouts, file copy/open/editor actions, and "N unmodified lines" markers between hunks. The footer switches between all changes, uncommitted work, and individual commits while showing how far the branch is ahead of its merge base; committed branches also provide a copyable local sync command. 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. - The thread workspace rail in each Chat pane lists thread files, project files, and artifacts. It docks to the pane's right edge by default; drag its header (or use the dock button) to move it to the bottom, and the choice is stored in the current browser profile. A collapsed rail takes no space at all: reopen it with ⇧⌘B or the files toggle in the title bar, which carries a changed-file count badge. The separate file, tool, and Canvas detail panel is unaffected. - File paths recognized in chat messages read as their basename with a small glyph for the file type in front — a Markdown page, a `package.json` manifest, a TypeScript source, a `.tsx` component, a config or data file, a shell script, and an image each get their own mark, and anything else falls back to a plain document. When two links in the same message share a basename, each keeps just enough of its trailing path to stay distinct. The full path stays on the link: it is what the tooltip shows, what opens in the file panel, and what the message's **Copy** action returns, since copy hands back the original Markdown. Labels you write yourself in a `[label](path)` link are never rewritten. The glyph is drawn from the bundled icon set, never fetched from the network, and is decorative only: it is not read by screen readers and is not part of copied text. Text that is not a recognizable path — anything carrying spaces, parentheses, a `#` fragment, or a `?` query — stays plain prose. diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index a4ffa3eeba41..541c47409065 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -244,8 +244,10 @@ export { SessionsFilesListResultSchema, SessionsFilesRevealParamsSchema, SessionsFilesRevealResultSchema, + SessionDiffCommitSchema, SessionDiffFileSchema, SessionDiffFileStatusSchema, + SessionDiffScopeSchema, SessionsDiffParamsSchema, SessionsDiffResultSchema, SessionsCompactionListParamsSchema, diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts index f58f0f2a2d59..2255ee5dbe3b 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts @@ -33,6 +33,8 @@ export const SessionLifecycleProtocolSchemas = { SessionsFilesSetResult: sessions.SessionsFilesSetResultSchema, SessionDiffFileStatus: sessions.SessionDiffFileStatusSchema, SessionDiffFile: sessions.SessionDiffFileSchema, + SessionDiffCommit: sessions.SessionDiffCommitSchema, + SessionDiffScope: sessions.SessionDiffScopeSchema, SessionsDiffParams: sessions.SessionsDiffParamsSchema, SessionsDiffResult: sessions.SessionsDiffResultSchema, SessionWorktreeInfo: sessions.SessionWorktreeInfoSchema, diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 461ffe2e633a..cd69c84831df 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -321,10 +321,25 @@ export const SessionDiffFileSchema = closedObject({ truncated: Type.Optional(Type.Boolean()), }); +/** One commit shown in session diff branch metadata. */ +export const SessionDiffCommitSchema = closedObject({ + sha: NonEmptyString, + subject: Type.String(), +}); + +/** Selects the session checkout state represented by the diff. */ +export const SessionDiffScopeSchema = Type.Union([ + Type.Literal("all"), + Type.Literal("uncommitted"), + Type.Literal("commit"), +]); + /** Reads the git diff of a session checkout against its base branch. */ export const SessionsDiffParamsSchema = closedObject({ sessionKey: NonEmptyString, agentId: Type.Optional(NonEmptyString), + scope: Type.Optional(SessionDiffScopeSchema), + commit: Type.Optional(NonEmptyString), }); /** Branch + working-tree diff for one session checkout. */ @@ -334,12 +349,22 @@ export const SessionsDiffResultSchema = closedObject({ branch: Type.Optional(NonEmptyString), /** Display label of the diff base: the default branch name or "HEAD". */ baseRef: Type.Optional(NonEmptyString), + /** Number of commits between the resolved branch merge base and HEAD. */ + aheadCount: Type.Optional(Type.Integer({ minimum: 0 })), + /** Newest-first commits between the resolved branch merge base and HEAD. */ + commits: Type.Optional(Type.Array(SessionDiffCommitSchema, { maxItems: 50 })), + /** The resolved branch merge-base commit. */ + mergeBase: Type.Optional(SessionDiffCommitSchema), files: Type.Array(SessionDiffFileSchema), additions: Type.Integer({ minimum: 0 }), deletions: Type.Integer({ minimum: 0 }), truncated: Type.Optional(Type.Boolean()), unavailableReason: Type.Optional( - Type.Union([Type.Literal("unknown_session"), Type.Literal("not_git")]), + Type.Union([ + Type.Literal("unknown_session"), + Type.Literal("not_git"), + Type.Literal("unknown_commit"), + ]), ), }); @@ -823,5 +848,7 @@ export type SessionsFilesRevealParams = Static; export type SessionDiffFileStatus = Static; export type SessionDiffFile = Static; +export type SessionDiffCommit = Static; +export type SessionDiffScope = Static; export type SessionsDiffParams = Static; export type SessionsDiffResult = Static; diff --git a/src/gateway/server-methods/sessions-diff.test.ts b/src/gateway/server-methods/sessions-diff.test.ts index d78163d0faa5..8c3daa622d64 100644 --- a/src/gateway/server-methods/sessions-diff.test.ts +++ b/src/gateway/server-methods/sessions-diff.test.ts @@ -197,6 +197,7 @@ describe("loadSessionDiff", () => { fs.writeFileSync(path.join(repoRoot, "a.txt"), "one\n"); git(repoRoot, "add", "."); git(repoRoot, "commit", "-qm", "init"); + const rootCommit = git(repoRoot, "rev-parse", "HEAD").trim(); fs.writeFileSync(path.join(repoRoot, "a.txt"), "one\nmore\n"); mockSession(repoRoot); @@ -205,6 +206,92 @@ describe("loadSessionDiff", () => { expect(result.baseRef).toBe("HEAD"); expect(result.files).toHaveLength(1); expect(result.files[0]?.additions).toBe(1); + + const committed = await loadSessionDiff({ + sessionKey: "agent:main:s1", + scope: "commit", + commit: rootCommit, + }); + expect(committed.unavailableReason).toBe("unknown_commit"); + expect(committed.files).toEqual([]); + }); + + it("scopes branch, working-tree, and commit diffs with branch metadata", async () => { + initRepo(repoRoot); + fs.writeFileSync(path.join(repoRoot, "base.txt"), "base\n"); + git(repoRoot, "add", "."); + git(repoRoot, "commit", "-qm", "base"); + const mergeBase = git(repoRoot, "rev-parse", "HEAD").trim(); + git(repoRoot, "checkout", "-qb", "sibling"); + fs.writeFileSync(path.join(repoRoot, "sibling.txt"), "sibling commit\n"); + git(repoRoot, "add", "."); + git(repoRoot, "commit", "-qm", "sibling change"); + const siblingCommit = git(repoRoot, "rev-parse", "HEAD").trim(); + git(repoRoot, "checkout", "-q", "main"); + git(repoRoot, "checkout", "-qb", "feature"); + + fs.writeFileSync(path.join(repoRoot, "first.txt"), "first commit\n"); + git(repoRoot, "add", "."); + git(repoRoot, "commit", "-qm", "first change"); + const firstCommit = git(repoRoot, "rev-parse", "HEAD").trim(); + fs.writeFileSync(path.join(repoRoot, "second.txt"), "second commit\n"); + git(repoRoot, "add", "."); + git(repoRoot, "commit", "-qm", "second change"); + const secondCommit = git(repoRoot, "rev-parse", "HEAD").trim(); + + fs.appendFileSync(path.join(repoRoot, "second.txt"), "working tree\n"); + fs.writeFileSync(path.join(repoRoot, "loose.txt"), "untracked\n"); + mockSession(repoRoot); + + const all = await loadSessionDiff({ sessionKey: "agent:main:s1" }); + expect(all.files.map((file) => file.path)).toEqual(["first.txt", "loose.txt", "second.txt"]); + expect(all.aheadCount).toBe(2); + expect(all.commits).toEqual([ + { sha: git(repoRoot, "rev-parse", "--short", secondCommit).trim(), subject: "second change" }, + { sha: git(repoRoot, "rev-parse", "--short", firstCommit).trim(), subject: "first change" }, + ]); + expect(all.mergeBase).toEqual({ + sha: git(repoRoot, "rev-parse", "--short", mergeBase).trim(), + subject: "base", + }); + + const uncommitted = await loadSessionDiff({ + sessionKey: "agent:main:s1", + scope: "uncommitted", + }); + expect(uncommitted.files.map((file) => file.path)).toEqual(["loose.txt", "second.txt"]); + expect(uncommitted.files.find((file) => file.path === "second.txt")?.patch).toContain( + "+working tree", + ); + + const baseline = await captureSessionDiffBaseline({ cwd: repoRoot, sessionId: "s1" }); + mockSession(repoRoot, { sessionDiffBaseline: baseline }); + const committed = await loadSessionDiff({ + sessionKey: "agent:main:s1", + scope: "commit", + commit: firstCommit, + }); + expect(committed.files.map((file) => file.path)).toEqual(["first.txt"]); + expect(committed.files[0]?.patch).toContain("+first commit"); + expect(committed.files[0]?.untracked).toBeUndefined(); + + for (const commit of [siblingCommit, mergeBase]) { + const outsideAdvertisedHistory = await loadSessionDiff({ + sessionKey: "agent:main:s1", + scope: "commit", + commit, + }); + expect(outsideAdvertisedHistory.unavailableReason).toBe("unknown_commit"); + expect(outsideAdvertisedHistory.files).toEqual([]); + } + + const unknown = await loadSessionDiff({ + sessionKey: "agent:main:s1", + scope: "commit", + commit: "not-a-commit", + }); + expect(unknown.unavailableReason).toBe("unknown_commit"); + expect(unknown.files).toEqual([]); }); it("never executes configured textconv drivers from the read RPC", async () => { @@ -363,19 +450,27 @@ describe("loadSessionDiff", () => { }); it("rejects invalid params through the handler", async () => { - const calls: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = []; - await sessionsDiffHandlers["sessions.diff"]?.({ - req: { type: "req", id: "sessions.diff", method: "sessions.diff", params: {} }, - params: {}, - client: null, - isWebchatConnect: () => false, - respond: (ok: boolean, payload?: unknown, error?: unknown) => { - calls.push({ ok, payload, error }); - }, - context: { getRuntimeConfig: () => ({}) } as never, - }); - expect(calls).toHaveLength(1); - expect(calls[0]?.ok).toBe(false); + const invalidParams = [ + {}, + { sessionKey: "agent:main:s1", scope: "commit" }, + { sessionKey: "agent:main:s1", scope: "all", commit: "HEAD" }, + { sessionKey: "agent:main:s1", scope: "uncommitted", commit: "HEAD" }, + ]; + for (const params of invalidParams) { + const calls: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = []; + await sessionsDiffHandlers["sessions.diff"]?.({ + req: { type: "req", id: "sessions.diff", method: "sessions.diff", params }, + params, + client: null, + isWebchatConnect: () => false, + respond: (ok: boolean, payload?: unknown, error?: unknown) => { + calls.push({ ok, payload, error }); + }, + context: { getRuntimeConfig: () => ({}) } as never, + }); + expect(calls).toHaveLength(1); + expect(calls[0]?.ok).toBe(false); + } }); }); diff --git a/src/gateway/server-methods/sessions-diff.ts b/src/gateway/server-methods/sessions-diff.ts index c93b0a06a80c..0d78b48ccc15 100644 --- a/src/gateway/server-methods/sessions-diff.ts +++ b/src/gateway/server-methods/sessions-diff.ts @@ -2,6 +2,8 @@ // working-tree state captured when the logical session started. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { + ErrorCodes, + errorShape, validateSessionsDiffParams, type SessionsDiffParams, type SessionsDiffResult, @@ -51,9 +53,24 @@ export async function loadSessionDiff(params: SessionsDiffParams): Promise Promise; + +/** Picks the merge base used for branch-relative session diffs. */ +export async function resolveSessionDiffBase(params: { + branch: string | undefined; + gitOut: GitOutput; + root: string; +}): Promise<{ base: string; baseRef: string }> { + const defaultRef = await params.gitOut(params.root, [ + "symbolic-ref", + "--short", + "refs/remotes/origin/HEAD", + ]); + const remoteDefault = defaultRef?.trim() || null; + const defaultShort = remoteDefault?.replace(/^origin\//, ""); + if (remoteDefault && defaultShort && params.branch && params.branch !== defaultShort) { + const mergeBase = await params.gitOut(params.root, ["merge-base", remoteDefault, "HEAD"]); + if (mergeBase?.trim()) { + return { base: mergeBase.trim(), baseRef: defaultShort }; + } + } + // Plain clones without origin/HEAD still get a branch-relative diff. + if (params.branch && params.branch !== "main" && params.branch !== "master") { + for (const candidate of ["main", "master"]) { + const verified = await params.gitOut(params.root, [ + "rev-parse", + "--verify", + "--quiet", + candidate, + ]); + if (verified?.trim()) { + const mergeBase = await params.gitOut(params.root, ["merge-base", candidate, "HEAD"]); + if (mergeBase?.trim()) { + return { base: mergeBase.trim(), baseRef: candidate }; + } + } + } + } + return { base: "HEAD", baseRef: "HEAD" }; +} + +/** Resolves the repository-format-specific empty tree without writing it. */ +export async function resolveSessionDiffEmptyTree( + root: string, +): Promise<{ base: string; baseRef?: string } | null> { + try { + const result = await runGit(root, ["hash-object", "-t", "tree", "--stdin"], { input: "" }); + const emptyTree = result.code === 0 ? result.stdout.trim() : ""; + return emptyTree ? { base: emptyTree } : null; + } catch { + return null; + } +} + +type BranchDiffMetadata = Pick; + +function parseCommitRecord(line: string): NonNullable | undefined { + const separator = line.indexOf("\0"); + if (separator <= 0) { + return undefined; + } + return { sha: line.slice(0, separator), subject: line.slice(separator + 1) }; +} + +function parseCommitRecords(text: string): NonNullable { + return text + .split("\n") + .map(parseCommitRecord) + .filter( + (record): record is NonNullable => record !== undefined, + ); +} + +/** Loads the bounded branch history metadata shared by every diff scope. */ +export async function loadSessionDiffBranchMetadata(params: { + base: string; + gitOut: GitOutput; + head: string; + root: string; +}): Promise { + if (params.base === "HEAD" || params.base === params.head) { + return {}; + } + const range = `${params.base}..HEAD`; + const [aheadText, commitsText, mergeBaseText] = await Promise.all([ + params.gitOut(params.root, ["rev-list", "--count", range]), + params.gitOut(params.root, ["log", "--max-count=50", "--format=%h%x00%s", range, "--"]), + params.gitOut(params.root, ["show", "--no-patch", "--format=%h%x00%s", params.base, "--"]), + ]); + const normalizedAhead = aheadText?.trim(); + const aheadCount = + normalizedAhead && /^\d+$/.test(normalizedAhead) + ? Number.parseInt(normalizedAhead, 10) + : undefined; + const mergeBase = mergeBaseText ? parseCommitRecords(mergeBaseText)[0] : undefined; + return { + ...(aheadCount !== undefined ? { aheadCount } : {}), + ...(commitsText !== null ? { commits: parseCommitRecords(commitsText) } : {}), + ...(mergeBase ? { mergeBase } : {}), + }; +} diff --git a/src/sessions/session-diff.ts b/src/sessions/session-diff.ts index e8a93cef1da2..c66456a65942 100644 --- a/src/sessions/session-diff.ts +++ b/src/sessions/session-diff.ts @@ -11,6 +11,11 @@ import type { import { runGit } from "../agents/worktrees/git.js"; import type { SessionDiffBaseline } from "../config/sessions/types.js"; import { runCommandBuffered } from "../process/exec.js"; +import { + loadSessionDiffBranchMetadata, + resolveSessionDiffBase, + resolveSessionDiffEmptyTree, +} from "./session-diff-revisions.js"; const MAX_FILES = 500; const MAX_UNTRACKED_FILES = 100; @@ -207,58 +212,6 @@ function takePatch( return { patch: chunk }; } -/** - * Picks the ref the session diff is computed against: merge-base with the - * remote default branch when on a feature branch, otherwise HEAD so sessions - * on the default branch still surface uncommitted work. - */ -async function resolveDiffBase( - root: string, - branch: string | undefined, -): Promise<{ base: string; baseRef: string }> { - const defaultRef = await gitOut(root, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]); - const remoteDefault = defaultRef?.trim() || null; - const defaultShort = remoteDefault?.replace(/^origin\//, ""); - if (remoteDefault && defaultShort && branch && branch !== defaultShort) { - const mergeBase = await gitOut(root, ["merge-base", remoteDefault, "HEAD"]); - if (mergeBase?.trim()) { - return { base: mergeBase.trim(), baseRef: defaultShort }; - } - } - // No usable remote default: try a local main/master so plain clones still - // get a branch-relative diff instead of only uncommitted changes. - if (branch && branch !== "main" && branch !== "master") { - for (const candidate of ["main", "master"]) { - const verified = await gitOut(root, ["rev-parse", "--verify", "--quiet", candidate]); - if (verified?.trim()) { - const mergeBase = await gitOut(root, ["merge-base", candidate, "HEAD"]); - if (mergeBase?.trim()) { - return { base: mergeBase.trim(), baseRef: candidate }; - } - } - } - } - return { base: "HEAD", baseRef: "HEAD" }; -} - -/** - * Diff base for a repo before its first commit: the empty-tree object id, so - * `git diff ` reports staged/index files as additions. `hash-object` - * derives the id for the repo's object format (SHA-1 vs SHA-256) and does not - * write to the object DB. baseRef stays undefined — there is no named base. - */ -async function resolveUnbornDiffBase( - root: string, -): Promise<{ base: string; baseRef?: string } | null> { - try { - const result = await runGit(root, ["hash-object", "-t", "tree", "--stdin"], { input: "" }); - const emptyTree = result.code === 0 ? result.stdout.trim() : ""; - return emptyTree ? { base: emptyTree } : null; - } catch { - return null; - } -} - async function collectUntrackedFiles( root: string, realRoot: string, @@ -336,11 +289,11 @@ async function collectUntrackedFiles( async function collectTrackedFiles( root: string, realRoot: string, - base: string, + revisions: readonly [base: string] | readonly [base: string, target: string], budget: PatchBudget, ): Promise<{ files: SessionDiffFile[]; truncated: boolean }> { - const diffArgs = ["diff", "-M", base]; - const nameStatus = await gitOut(root, [...diffArgs, "--name-status", "-z"]); + const diffArgs = (options: string[]) => ["diff", "-M", ...options, ...revisions, "--"]; + const nameStatus = await gitOut(root, diffArgs(["--name-status", "-z"])); if (nameStatus === null) { return { files: [], truncated: false }; } @@ -348,7 +301,7 @@ async function collectTrackedFiles( if (entries.length === 0) { return { files: [], truncated: false }; } - const numstatText = (await gitOut(root, [...diffArgs, "--numstat", "-z"])) ?? ""; + const numstatText = (await gitOut(root, diffArgs(["--numstat", "-z"]))) ?? ""; const numstat = parseNumstatZ(numstatText); const totalChangedLines = [...numstat.values()].reduce( (sum, entry) => sum + entry.additions + entry.deletions, @@ -359,13 +312,7 @@ async function collectTrackedFiles( const patchText = totalChangedLines > MAX_TOTAL_CHANGED_LINES ? null - : await gitOut(root, [ - ...diffArgs, - "--patch", - "--no-color", - "--no-ext-diff", - "--no-textconv", - ]); + : await gitOut(root, diffArgs(["--patch", "--no-color", "--no-ext-diff", "--no-textconv"])); const chunks = patchText === null ? new Map() : splitPatchByFile(patchText); const truncated = entries.length > MAX_FILES; const files: SessionDiffFile[] = []; @@ -387,11 +334,12 @@ async function collectTrackedFiles( files.push(file); continue; } - // Deleted files diff against the object DB (no filesystem read); every - // other status reads the working-tree file, so hardlink-guard it before - // returning content the bulk diff already buffered server-side. + // Like the deleted-file exemption, two-revision commit diffs read every + // path from the object DB. Only working-tree content needs the hardlink guard. const safe = - entry.status === "deleted" || (await isPatchableWorkingTreePath(realRoot, entry.path)); + revisions.length === 2 || + entry.status === "deleted" || + (await isPatchableWorkingTreePath(realRoot, entry.path)); if (!safe) { file.truncated = true; files.push(file); @@ -409,10 +357,12 @@ async function collectTrackedFiles( return { files, truncated }; } -export async function loadCheckoutDiff(params: { - cwd: string; - sessionKey: string; -}): Promise { +type CheckoutDiffParams = { cwd: string; sessionKey: string } & ( + | { scope?: "all" | "uncommitted"; commit?: never } + | { scope: "commit"; commit: string } +); + +export async function loadCheckoutDiff(params: CheckoutDiffParams): Promise { const empty = ( unavailableReason?: NonNullable, ): SessionsDiffResult => ({ @@ -431,19 +381,71 @@ export async function loadCheckoutDiff(params: { const realRoot = await fs.realpath(root).catch(() => root); const branchOut = (await gitOut(root, ["rev-parse", "--abbrev-ref", "HEAD"]))?.trim(); const branch = branchOut && branchOut !== "HEAD" ? branchOut : undefined; + const head = (await gitOut(root, ["rev-parse", "--verify", "--quiet", "HEAD"]))?.trim(); + const branchBase = head + ? await resolveSessionDiffBase({ branch, gitOut, root }) + : await resolveSessionDiffEmptyTree(root); + const metadata = + head && branchBase + ? await loadSessionDiffBranchMetadata({ base: branchBase.base, gitOut, head, root }) + : {}; + const repositoryFields = { + sessionKey: params.sessionKey, + root, + ...(branch ? { branch } : {}), + ...(branchBase?.baseRef ? { baseRef: branchBase.baseRef } : {}), + ...metadata, + }; + const unknownCommit = (): SessionsDiffResult => ({ + ...repositoryFields, + files: [], + additions: 0, + deletions: 0, + unavailableReason: "unknown_commit", + }); + const scope = params.scope ?? "all"; + let revisions: readonly [string] | readonly [string, string] | undefined; + if (scope === "commit") { + if (!head || !branchBase || branchBase.base === "HEAD" || branchBase.base === head) { + return unknownCommit(); + } + const commit = ( + await gitOut(root, [ + "rev-parse", + "--verify", + "--quiet", + "--end-of-options", + `${params.commit}^{commit}`, + ]) + )?.trim(); + if (!commit) { + return unknownCommit(); + } + // Commit scope is fenced to the advertised merge-base..HEAD history so an + // operator.read client cannot read arbitrary commits from the object database. + const isCommitInHeadHistory = + (await gitOut(root, ["merge-base", "--is-ancestor", commit, "HEAD"], [0])) !== null; + const isCommitInBaseHistory = + (await gitOut(root, ["merge-base", "--is-ancestor", commit, branchBase.base], [0])) !== null; + if (!isCommitInHeadHistory || isCommitInBaseHistory) { + return unknownCommit(); + } + const parent = (await gitOut(root, ["rev-parse", "--verify", "--quiet", `${commit}^`]))?.trim(); + const commitBase = parent ? { base: parent } : await resolveSessionDiffEmptyTree(root); + revisions = commitBase ? [commitBase.base, commit] : undefined; + } else if (scope === "uncommitted") { + revisions = head ? ["HEAD"] : branchBase ? [branchBase.base] : undefined; + } else { + revisions = branchBase ? [branchBase.base] : undefined; + } const budget: PatchBudget = { remaining: MAX_TOTAL_PATCH_BYTES }; - // Repos before their first commit have no HEAD, so diff the index/worktree - // against the empty tree to surface staged files (the untracked scan below - // only covers files git does not track yet). hash-object derives the empty - // tree id for the repo's object format without writing to the object DB. - const hasHead = (await gitOut(root, ["rev-parse", "--verify", "--quiet", "HEAD"])) !== null; - const baseInfo = hasHead - ? await resolveDiffBase(root, branch) - : await resolveUnbornDiffBase(root); - const tracked = baseInfo - ? await collectTrackedFiles(root, realRoot, baseInfo.base, budget) + const tracked = revisions + ? await collectTrackedFiles(root, realRoot, revisions, budget) : { files: [], truncated: false }; - const untracked = await collectUntrackedFiles(root, realRoot, budget); + const untracked = + scope === "commit" + ? { files: [], truncated: false } + : await collectUntrackedFiles(root, realRoot, budget); const files = [...tracked.files, ...untracked.files].toSorted((a, b) => a.path.localeCompare(b.path), ); @@ -452,10 +454,7 @@ export async function loadCheckoutDiff(params: { const truncated = tracked.truncated || untracked.truncated || files.some((file) => file.truncated === true); return { - sessionKey: params.sessionKey, - root, - ...(branch ? { branch } : {}), - ...(baseInfo?.baseRef ? { baseRef: baseInfo.baseRef } : {}), + ...repositoryFields, files, additions, deletions, @@ -611,8 +610,8 @@ async function collectBaselineCandidates(params: { const branch = branchOut && branchOut !== "HEAD" ? branchOut : undefined; const hasHead = (await gitOut(root, ["rev-parse", "--verify", "--quiet", "HEAD"])) !== null; const baseInfo = hasHead - ? await resolveDiffBase(root, branch) - : await resolveUnbornDiffBase(root); + ? await resolveSessionDiffBase({ branch, gitOut, root }) + : await resolveSessionDiffEmptyTree(root); const trackedText = baseInfo ? await gitOutForBaseline(root, ["diff", "-M", baseInfo.base, "--name-status", "-z"]) : ""; diff --git a/ui/src/e2e/chat-session-diff.e2e.test.ts b/ui/src/e2e/chat-session-diff.e2e.test.ts index afd6489ec9f3..76f9e4ce9521 100644 --- a/ui/src/e2e/chat-session-diff.e2e.test.ts +++ b/ui/src/e2e/chat-session-diff.e2e.test.ts @@ -84,40 +84,99 @@ describeControlUiE2e("session diff panel", () => { it("opens the diff sidebar with per-file patches and gap markers", async () => { const context = await newBrowserContext(); const page = await context.newPage(); - await installMockGateway(page, { + const metadata = { + aheadCount: 2, + commits: [ + { sha: "def5678", subject: "Second feature change" }, + { sha: "abc1234", subject: "First feature change" }, + ], + mergeBase: { sha: "0011223", subject: "Initial commit" }, + }; + const gateway = await installMockGateway(page, { featureMethods: ["chat.metadata", "chat.startup", "sessions.diff"], methodResponses: { "sessions.diff": { - sessionKey: "main", - root: "/tmp/checkout", - branch: "feature/panel", - baseRef: "main", - files: [ + cases: [ { - path: "src/app.ts", - status: "modified", - additions: 2, - deletions: 1, - patch: APP_PATCH, + match: { scope: "uncommitted" }, + response: { + sessionKey: "main", + root: "/tmp/checkout", + branch: "feature/panel", + baseRef: "main", + ...metadata, + files: [ + { + path: "notes.md", + status: "added", + additions: 2, + deletions: 0, + untracked: true, + patch: NOTES_PATCH, + }, + ], + additions: 2, + deletions: 0, + }, }, { - path: "notes.md", - status: "added", - additions: 2, - deletions: 0, - untracked: true, - patch: NOTES_PATCH, + match: { scope: "commit", commit: "abc1234" }, + response: { + sessionKey: "main", + root: "/tmp/checkout", + branch: "feature/panel", + baseRef: "main", + ...metadata, + files: [ + { + path: "src/app.ts", + status: "modified", + additions: 2, + deletions: 1, + patch: APP_PATCH, + }, + ], + additions: 2, + deletions: 1, + }, }, { - path: "logo.png", - status: "modified", - additions: 0, - deletions: 0, - binary: true, + match: { scope: "all" }, + response: { + sessionKey: "main", + root: "/tmp/checkout", + branch: "feature/panel", + baseRef: "main", + ...metadata, + files: [ + { + path: "src/app.ts", + status: "modified", + additions: 2, + deletions: 1, + patch: APP_PATCH, + }, + { + path: "notes.md", + status: "added", + additions: 2, + deletions: 0, + untracked: true, + patch: NOTES_PATCH, + }, + { + path: "logo.png", + status: "modified", + additions: 0, + deletions: 0, + binary: true, + }, + ], + additions: 4, + deletions: 1, + }, }, ], - additions: 4, - deletions: 1, }, }, }); @@ -130,14 +189,23 @@ describeControlUiE2e("session diff panel", () => { await expect .poll(() => panel.locator(".session-diff__branch-label").textContent()) .toBe("main → feature/panel"); + await expect + .poll(async () => + (await panel.locator(".session-diff__summary .chat-diffstat").textContent())?.replace( + /\s/g, + "", + ), + ) + .toBe("+3~1"); const files = panel.locator(".session-diff__file"); await expect.poll(() => files.count()).toBe(3); const modified = files.first(); await expect - .poll(() => modified.locator(".session-diff__path").textContent()) - .toContain("src/app.ts"); + .poll(() => modified.locator(".session-diff__filename").textContent()) + .toBe("app.ts"); + await expect.poll(() => modified.locator(".session-diff__directory").textContent()).toBe("src"); // Hunk starting at old line 10 renders a leading gap marker. await expect .poll(() => modified.locator(".chat-diff__row--skip").first().textContent()) @@ -156,11 +224,47 @@ describeControlUiE2e("session diff panel", () => { .poll(() => binary.locator(".session-diff__note").textContent()) .toContain("Binary file"); - // Collapsing a file hides its diff body. - await modified.locator(".session-diff__file-header").click(); - await expect.poll(() => modified.locator(".chat-diff").count()).toBe(0); - await modified.locator(".session-diff__file-header").click(); + await panel.getByRole("button", { name: "Change view options" }).click(); + await page.getByRole("menuitem", { name: "Switch to Split Diff" }).click(); + await expect.poll(() => modified.locator(".session-diff-split").count()).toBe(1); + await panel.getByRole("button", { name: "Change view options" }).click(); + await page.getByRole("menuitem", { name: "Switch to Unified Diff" }).click(); await expect.poll(() => modified.locator(".chat-diff").count()).toBe(1); + // View-only toggles reuse parsed patches and do not refetch the RPC. + await expect.poll(async () => (await gateway.getRequests("sessions.diff")).length).toBe(1); + + // Collapsing a file hides its diff body. + await modified.locator(".session-diff__file-toggle").click(); + await expect.poll(() => modified.locator(".chat-diff").count()).toBe(0); + await panel.getByRole("button", { name: "Refresh changes" }).click(); + await expect.poll(async () => (await gateway.getRequests("sessions.diff")).length).toBe(2); + // Refresh keeps the current collapse state instead of expanding every file. + await expect.poll(() => modified.locator(".chat-diff").count()).toBe(0); + await modified.locator(".session-diff__file-toggle").click(); + await expect.poll(() => modified.locator(".chat-diff").count()).toBe(1); + + await panel.getByRole("button", { name: "Choose change scope" }).click(); + await page + .locator('openclaw-session-diff-menu wa-dropdown-item[value="scope:uncommitted"]') + .click(); + await expect + .poll(() => panel.locator(".session-diff__section-title").textContent()) + .toBe("Uncommitted"); + await expect.poll(() => panel.locator(".session-diff__file").count()).toBe(1); + await expect + .poll(async () => (await gateway.getRequests("sessions.diff")).at(-1)?.params) + .toMatchObject({ scope: "uncommitted" }); + + await panel.getByRole("button", { name: "Choose change scope" }).click(); + await page + .locator('openclaw-session-diff-menu wa-dropdown-item[value="scope:commit:abc1234"]') + .click(); + await expect + .poll(() => panel.locator(".session-diff__section-title").textContent()) + .toBe("abc1234 First feature change"); + await expect + .poll(async () => (await gateway.getRequests("sessions.diff")).at(-1)?.params) + .toMatchObject({ scope: "commit", commit: "abc1234" }); }); it("hides the diff toggle until the workspace becomes a git checkout", async () => { diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 36703db25c4b..2d151f3cef94 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5406,7 +5406,34 @@ export const en: TranslationMap = { empty: "No changes in this session's checkout.", notGit: "This session's workspace is not a git checkout.", unknownSession: "No workspace is associated with this session.", + unknownCommit: "This commit is no longer available in the session checkout.", disconnected: "Gateway is disconnected.", + allChanges: "All Changes", + uncommitted: "Uncommitted", + commitsAhead: "{count} commits ahead of {base}", + head: "HEAD", + mergeBase: "Merge Base", + scopeMenu: "Choose change scope", + sync: "Sync", + syncLocally: "Sync Locally", + syncDescription: + "Run this command in a local checkout to mirror this session's committed changes.", + copyCommand: "Copy sync command", + checkoutPath: "Checkout path", + branchName: "Branch name", + uncommittedStay: "Uncommitted changes stay in the session checkout.", + viewOptions: "Change view options", + collapseAll: "Collapse All", + expandAll: "Expand All", + enableWrapping: "Enable Wrapping", + disableWrapping: "Disable Wrapping", + switchSplit: "Switch to Split Diff", + switchUnified: "Switch to Unified Diff", + fileActions: "Actions for {path}", + copyPath: "Copy Path", + openFile: "Open File", + openInEditor: "Open in Editor", + revealInFileTree: "Reveal in File Tree", unmodifiedLines: "{count} unmodified lines", binaryFile: "Binary file", untracked: "untracked", diff --git a/ui/src/lib/chat/session-diff-split.test.ts b/ui/src/lib/chat/session-diff-split.test.ts new file mode 100644 index 000000000000..5f5c39b1cca2 --- /dev/null +++ b/ui/src/lib/chat/session-diff-split.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { pairSessionDiffLines } from "./session-diff-split.ts"; +import type { DiffLine } from "./tool-call-diff.ts"; + +describe("pairSessionDiffLines", () => { + it("pairs uneven change runs and spans context and gap rows", () => { + const lines: DiffLine[] = [ + { kind: "ctx", lineNo: 3, text: "before" }, + { kind: "del", lineNo: 4, text: "old one" }, + { kind: "del", lineNo: 5, text: "old two" }, + { kind: "add", lineNo: 4, text: "new" }, + { kind: "skip", text: "8 unmodified lines" }, + ]; + + expect(pairSessionDiffLines(lines)).toEqual([ + { kind: "span", line: lines[0] }, + { kind: "pair", left: lines[1], right: lines[3] }, + { kind: "pair", left: lines[2] }, + { kind: "span", line: lines[4] }, + ]); + }); +}); diff --git a/ui/src/lib/chat/session-diff-split.ts b/ui/src/lib/chat/session-diff-split.ts new file mode 100644 index 000000000000..dcc68685c97a --- /dev/null +++ b/ui/src/lib/chat/session-diff-split.ts @@ -0,0 +1,44 @@ +import type { DiffLine } from "./tool-call-diff.ts"; + +export type SessionSplitDiffRow = + | { kind: "pair"; left?: DiffLine; right?: DiffLine } + | { kind: "span"; line: DiffLine }; + +/** Aligns each adjacent deletion/addition block while keeping context and gaps full-width. */ +export function pairSessionDiffLines(lines: readonly DiffLine[]): SessionSplitDiffRow[] { + const rows: SessionSplitDiffRow[] = []; + for (let index = 0; index < lines.length;) { + const line = lines[index]; + if (!line) { + break; + } + if (line.kind !== "add" && line.kind !== "del") { + rows.push({ kind: "span", line }); + index += 1; + continue; + } + + const deletions: DiffLine[] = []; + const additions: DiffLine[] = []; + while (index < lines.length) { + const changed = lines[index]; + if (changed?.kind === "del") { + deletions.push(changed); + } else if (changed?.kind === "add") { + additions.push(changed); + } else { + break; + } + index += 1; + } + const count = Math.max(deletions.length, additions.length); + for (let pairIndex = 0; pairIndex < count; pairIndex += 1) { + rows.push({ + kind: "pair", + ...(deletions[pairIndex] ? { left: deletions[pairIndex] } : {}), + ...(additions[pairIndex] ? { right: additions[pairIndex] } : {}), + }); + } + } + return rows; +} diff --git a/ui/src/pages/chat/components/chat-diff-render.ts b/ui/src/pages/chat/components/chat-diff-render.ts index f9103cc08714..5a4ee6c19e25 100644 --- a/ui/src/pages/chat/components/chat-diff-render.ts +++ b/ui/src/pages/chat/components/chat-diff-render.ts @@ -6,13 +6,24 @@ import { t } from "../../../i18n/index.ts"; import type { ToolCardOutcome } from "../../../lib/chat/chat-types.ts"; import type { DiffLine, DiffStat } from "../../../lib/chat/tool-call-diff.ts"; -export function renderDiffStatChips(stat: DiffStat) { - if (stat.added === 0 && stat.removed === 0) { +export function renderDiffStatChips(stat: DiffStat & { modified?: number }) { + // Tool cards omit `modified`; keep their original template byte-for-byte. + if (stat.modified === undefined) { + if (stat.added === 0 && stat.removed === 0) { + return nothing; + } + return html` + ${stat.added > 0 ? html`+${stat.added}` : nothing} + ${stat.removed > 0 ? html`-${stat.removed}` : nothing} + `; + } + if (stat.added === 0 && stat.removed === 0 && !stat.modified) { return nothing; } return html` ${stat.added > 0 ? html`+${stat.added}` : nothing} ${stat.removed > 0 ? html`-${stat.removed}` : nothing} + ${stat.modified > 0 ? html`~${stat.modified}` : nothing} `; } diff --git a/ui/src/pages/chat/components/chat-session-workspace.ts b/ui/src/pages/chat/components/chat-session-workspace.ts index c975f5ea0c81..ed5952cc02a6 100644 --- a/ui/src/pages/chat/components/chat-session-workspace.ts +++ b/ui/src/pages/chat/components/chat-session-workspace.ts @@ -837,15 +837,18 @@ function buildSessionDiffSidebarContent(state: SessionWorkspaceHost): SidebarCon const sessionKey = state.sessionKey; return { kind: "session-diff", - load: async () => { + load: async (scope) => { if (!state.client) { throw new Error(t("chat.sessionDiff.disconnected")); } return await state.client.request("sessions.diff", { sessionKey, ...scopedAgentParamsForSession(state, sessionKey), + ...scope, }); }, + openFile: (path) => openFile(state, getWorkspaceState(state), path), + revealFile: (path) => revealSessionWorkspaceFile(state, path), }; } diff --git a/ui/src/pages/chat/components/chat-sidebar.ts b/ui/src/pages/chat/components/chat-sidebar.ts index 0fe6841c68df..52006938c0f3 100644 --- a/ui/src/pages/chat/components/chat-sidebar.ts +++ b/ui/src/pages/chat/components/chat-sidebar.ts @@ -81,6 +81,8 @@ type SessionDiffSidebarContent = { kind: "session-diff"; /** Fetches a fresh sessions.diff snapshot; the panel refetches on refresh. */ load: SessionDiffLoader; + openFile?: (path: string) => void; + revealFile?: (path: string) => void; rawText?: string | null; fullMessageRequest?: SidebarFullMessageRequest; unavailableReason?: DetailUnavailableReason | null; @@ -590,7 +592,11 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) { ? content.kind === "file" ? renderFileSidebarContent(content, props.onViewRawText, props.fileView) : content.kind === "session-diff" - ? html`` + ? html`` : content.kind === "canvas" ? html`
@@ -1335,7 +1341,9 @@ class ChatDetailPanel extends OpenClawLightDomElement { // Markdown previews and file editors need a bounded host wrapper so their // inner content can shrink and scroll. Content-sized kinds keep auto height. const fillHost = - this.visibleContent?.kind === "file" || this.visibleContent?.kind === "markdown"; + this.visibleContent?.kind === "file" || + this.visibleContent?.kind === "markdown" || + this.visibleContent?.kind === "session-diff"; return html`
= T extends unknown ? Omit : never; +export type SessionDiffMenuDraft = WithoutMenuAnchor; + +export type SessionDiffMenuAction = + | { kind: "collapse-all" } + | { kind: "copy-path"; path: string } + | { kind: "expand-all" } + | { kind: "open-editor"; editor: EditorId; path: string } + | { kind: "open-file"; path: string } + | { kind: "reveal-file"; path: string } + | { kind: "scope"; value: SessionDiffScope } + | { kind: "toggle-split" } + | { kind: "toggle-wrap" }; + +class SessionDiffMenu extends OpenClawLightDomElement { + @property({ attribute: false }) menu: SessionDiffMenuData | null = null; + @property({ attribute: false }) onAction: (action: SessionDiffMenuAction) => void = () => {}; + @property({ attribute: false }) onClose: () => void = () => {}; + + readonly menuLifecycle = new DropdownMenuController(this, { + getTrigger: () => this.menu?.trigger ?? null, + onClose: () => this.onClose(), + }); + + override connectedCallback() { + super.connectedCallback(); + promoteToPopoverTopLayer(this); + } + + private run(action: SessionDiffMenuAction) { + this.onClose(); + this.onAction(action); + } + + private readonly handleSelect = (event: CustomEvent<{ item: { value?: string } }>) => { + event.preventDefault(); + const value = event.detail.item.value; + if (!value) { + return; + } + const simple: Record = { + "collapse-all": { kind: "collapse-all" }, + "expand-all": { kind: "expand-all" }, + "toggle-split": { kind: "toggle-split" }, + "toggle-wrap": { kind: "toggle-wrap" }, + "scope:all": { kind: "scope", value: { scope: "all" } }, + "scope:uncommitted": { kind: "scope", value: { scope: "uncommitted" } }, + }; + const fileMenu = this.menu?.kind === "file" ? this.menu : null; + if (fileMenu && value === "copy-path") { + this.run({ kind: "copy-path", path: fileMenu.path }); + return; + } + if (fileMenu && value === "open-file") { + this.run({ kind: "open-file", path: fileMenu.path }); + return; + } + if (fileMenu && value === "reveal-file") { + this.run({ kind: "reveal-file", path: fileMenu.path }); + return; + } + const action = simple[value]; + if (action) { + this.run(action); + return; + } + if (value.startsWith("open-editor:")) { + const editor = value.slice("open-editor:".length) as EditorId; + if (EDITOR_IDS.includes(editor)) { + const path = this.menu?.kind === "file" ? this.menu.absolutePath : undefined; + if (path) { + this.run({ kind: "open-editor", editor, path }); + } + } + return; + } + if (value.startsWith("scope:commit:")) { + this.run({ + kind: "scope", + value: { scope: "commit", commit: value.slice("scope:commit:".length) }, + }); + } + }; + + private readonly handleAfterHide = (event: Event) => { + if (event.currentTarget instanceof Node && event.currentTarget.isConnected) { + this.onClose(); + } + }; + + private renderFileMenu(menu: Extract) { + return html` + + + ${t("chat.sessionDiff.copyPath")} + + + + ${t("chat.sessionDiff.openFile")} + + ${menu.canReveal + ? html` + + ${t("chat.sessionDiff.revealInFileTree")} + ` + : nothing} + ${menu.absolutePath + ? html` + + ${t("chat.sessionDiff.openInEditor")} + ${EDITOR_IDS.map( + (editor) => html` + ${EDITOR_LABELS[editor]} + `, + )} + ` + : nothing} + `; + } + + private renderViewMenu(menu: Extract) { + return html` + + ${t("chat.sessionDiff.collapseAll")} + + + ${t("chat.sessionDiff.expandAll")} + + + + ${t( + menu.wrap ? "chat.sessionDiff.disableWrapping" : "chat.sessionDiff.enableWrapping", + )} + + + ${t( + menu.split ? "chat.sessionDiff.switchUnified" : "chat.sessionDiff.switchSplit", + )} + + `; + } + + private renderScopeMenu(menu: Extract) { + const activeCommit = menu.active.scope === "commit" ? menu.active.commit : null; + return html` + ${this.renderScopeItem( + "scope:all", + t("chat.sessionDiff.allChanges"), + menu.active.scope === "all", + )} + ${this.renderScopeItem( + "scope:uncommitted", + t("chat.sessionDiff.uncommitted"), + menu.active.scope === "uncommitted", + )} + ${menu.result.commits?.length + ? html` + ${menu.result.commits.map((commit, index) => + this.renderScopeItem( + `scope:commit:${commit.sha}`, + html`${commit.sha} + ${commit.subject} + ${index === 0 + ? html`${t("chat.sessionDiff.head")}` + : nothing}`, + activeCommit === commit.sha, + ), + )}` + : nothing} + ${menu.result.mergeBase + ? html` +
+ ${t("chat.sessionDiff.mergeBase")} + ${menu.result.mergeBase.sha} + ${menu.result.mergeBase.subject} +
` + : nothing} + `; + } + + private renderScopeItem(value: string, label: unknown, checked: boolean) { + return html` + ${label} + ${checked + ? html`` + : nothing} + `; + } + + private renderSyncMenu(menu: Extract) { + return html`
+ ${t("chat.sessionDiff.syncLocally")} +

${t("chat.sessionDiff.syncDescription")}

+ ${this.renderCopyRow(menu.command, t("chat.sessionDiff.copyCommand"), true)} + ${this.renderCopyRow(menu.root, t("chat.sessionDiff.checkoutPath"))} + ${this.renderCopyRow(menu.branch, t("chat.sessionDiff.branchName"))} +

${t("chat.sessionDiff.uncommittedStay")}

+
`; + } + + private renderCopyRow(value: string, label: string, command = false) { + return html`
+ ${label} + ${value} + ${renderCopyButton(value, label)} +
`; + } + + override render() { + const menu = this.menu; + if (!menu) { + return nothing; + } + const placement = menu.kind === "scope" ? "top-start" : "bottom-end"; + const width = menu.kind === "sync" ? 360 : menu.kind === "scope" ? 340 : 240; + const menuLabel = + menu.kind === "file" + ? t("chat.sessionDiff.fileActions", { path: menu.path }) + : menu.kind === "scope" + ? t("chat.sessionDiff.scopeMenu") + : menu.kind === "sync" + ? t("chat.sessionDiff.syncLocally") + : t("chat.sessionDiff.viewOptions"); + const clampedX = Math.max(8, Math.min(menu.anchor.x, window.innerWidth - 8)); + const clampedY = Math.max(8, Math.min(menu.anchor.y, window.innerHeight - 8)); + return html` + + ${menu.kind === "file" + ? this.renderFileMenu(menu) + : menu.kind === "scope" + ? this.renderScopeMenu(menu) + : menu.kind === "sync" + ? this.renderSyncMenu(menu) + : this.renderViewMenu(menu)} + `; + } +} + +if (!customElements.get("openclaw-session-diff-menu")) { + customElements.define("openclaw-session-diff-menu", SessionDiffMenu); +} + +declare global { + interface HTMLElementTagNameMap { + "openclaw-session-diff-menu": SessionDiffMenu; + } +} diff --git a/ui/src/pages/chat/components/session-diff-panel.test.ts b/ui/src/pages/chat/components/session-diff-panel.test.ts index 12c9be8857e9..08fd4a6e31be 100644 --- a/ui/src/pages/chat/components/session-diff-panel.test.ts +++ b/ui/src/pages/chat/components/session-diff-panel.test.ts @@ -44,6 +44,7 @@ describe("SessionDiffPanel", () => { document.body.append(panel); await vi.waitFor(() => expect(firstLoader).toHaveBeenCalledOnce()); + expect(firstLoader).toHaveBeenCalledWith({ scope: "all" }); panel.loader = secondLoader; await vi.waitFor(() => expect(secondLoader).toHaveBeenCalledOnce()); diff --git a/ui/src/pages/chat/components/session-diff-panel.ts b/ui/src/pages/chat/components/session-diff-panel.ts index f98a83c0ee4e..7db54f9d1ffd 100644 --- a/ui/src/pages/chat/components/session-diff-panel.ts +++ b/ui/src/pages/chat/components/session-diff-panel.ts @@ -1,8 +1,8 @@ +// Session diff panel: renders selectable branch, working-tree, and commit diffs. import { Task, TaskStatus } from "@lit/task"; -// Session diff panel: renders the sessions.diff RPC result (branch + -// working-tree changes per file) inside the chat detail sidebar. import { html, nothing, type TemplateResult } from "lit"; import { property, state } from "lit/decorators.js"; +import { keyed } from "lit/directives/keyed.js"; import type { SessionDiffFile, SessionsDiffResult, @@ -10,11 +10,26 @@ import type { import { icons } from "../../../components/icons.ts"; import "../../../components/tooltip.ts"; import { t } from "../../../i18n/index.ts"; +import { + pairSessionDiffLines, + type SessionSplitDiffRow, +} from "../../../lib/chat/session-diff-split.ts"; import { parseSessionDiffPatch, type ParsedFilePatch } from "../../../lib/chat/session-diff.ts"; +import { copyToClipboard } from "../../../lib/clipboard.ts"; +import { openEditor } from "../../../lib/editor-links.ts"; import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts"; +import { getSafeLocalStorage } from "../../../local-storage.ts"; import { renderDiffBlock, renderDiffStatChips } from "./chat-diff-render.ts"; +import type { + SessionDiffMenuAction, + SessionDiffMenuData, + SessionDiffMenuDraft, + SessionDiffScope, +} from "./session-diff-menus.ts"; +import "./session-diff-menus.ts"; +import { renderSessionSplitDiff } from "./session-diff-render.ts"; -export type SessionDiffLoader = () => Promise; +export type SessionDiffLoader = (params: SessionDiffScope) => Promise; type FileView = { file: SessionDiffFile; @@ -26,6 +41,29 @@ type SessionDiffTaskResult = { views: FileView[]; }; +type SessionDiffPreferences = { split: boolean; wrap: boolean }; +const PREFERENCES_KEY = "openclaw.control.sessionDiff.v1"; + +function loadPreferences(): SessionDiffPreferences { + try { + const parsed = JSON.parse(getSafeLocalStorage()?.getItem(PREFERENCES_KEY) ?? "null") as { + split?: unknown; + wrap?: unknown; + } | null; + return { split: parsed?.split === true, wrap: parsed?.wrap === true }; + } catch { + return { split: false, wrap: false }; + } +} + +function savePreferences(preferences: SessionDiffPreferences): void { + try { + getSafeLocalStorage()?.setItem(PREFERENCES_KEY, JSON.stringify(preferences)); + } catch { + // Preferences are opportunistic; restricted storage must not break the viewer. + } +} + function statusLabel(file: SessionDiffFile): string { switch (file.status) { case "added": @@ -39,18 +77,80 @@ function statusLabel(file: SessionDiffFile): string { } } +function statusLetter(file: SessionDiffFile): string { + return file.status === "added" + ? "A" + : file.status === "deleted" + ? "D" + : file.status === "renamed" + ? "R" + : "M"; +} + +function diffStat(file: Pick) { + const modified = Math.min(file.additions, file.deletions); + return { + added: file.additions - modified, + removed: file.deletions - modified, + modified, + }; +} + +function totalDiffStat(files: readonly SessionDiffFile[]) { + return files.reduce( + (total, file) => { + const stat = diffStat(file); + total.added += stat.added; + total.removed += stat.removed; + total.modified += stat.modified; + return total; + }, + { added: 0, removed: 0, modified: 0 }, + ); +} + +function splitPath(filePath: string): { directory: string; name: string } { + const normalized = filePath.replaceAll("\\", "/"); + const separator = normalized.lastIndexOf("/"); + return separator < 0 + ? { directory: "", name: normalized } + : { directory: normalized.slice(0, separator), name: normalized.slice(separator + 1) }; +} + +function absolutePath(root: string, filePath: string): string { + return `${root.replace(/[\\/]+$/, "")}/${filePath.replace(/^[\\/]+/, "")}`; +} + +function shellArgument(value: string): string { + return /^[A-Za-z0-9_./:@+-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`; +} + class SessionDiffPanel extends OpenClawLightDomElement { @property({ attribute: false }) loader: SessionDiffLoader | null = null; + @property({ attribute: false }) openFile: ((path: string) => void) | null = null; + @property({ attribute: false }) revealFile: ((path: string) => void) | null = null; @state() private collapsedPaths = new Set(); + @state() private menu: SessionDiffMenuData | null = null; + @state() private scope: SessionDiffScope = { scope: "all" }; + @state() private split = loadPreferences().split; + @state() private wrap = loadPreferences().wrap; + + private readonly splitCache = new WeakMap(); private readonly diffTask = new Task(this, { - args: () => [this.loader] as const, - task: async ([loader]): Promise => { + args: () => + [ + this.loader, + this.scope.scope, + this.scope.scope === "commit" ? this.scope.commit : null, + ] as const, + task: async ([loader, scope, commit]): Promise => { if (!loader) { return null; } - const result = await loader(); + const params: SessionDiffScope = scope === "commit" ? { scope, commit: commit! } : { scope }; + const result = await loader(params); return { result, views: result.files.map((file) => ({ @@ -63,8 +163,11 @@ class SessionDiffPanel extends OpenClawLightDomElement { })), }; }, - onComplete: () => { - this.collapsedPaths = new Set(); + onComplete: (value) => { + const currentPaths = new Set(value?.views.map((view) => view.file.path) ?? []); + this.collapsedPaths = new Set( + [...this.collapsedPaths].filter((path) => currentPaths.has(path)), + ); }, }); @@ -86,18 +189,102 @@ class SessionDiffPanel extends OpenClawLightDomElement { this.collapsedPaths = next; } + private openAnchoredMenu(event: Event, menu: SessionDiffMenuDraft, upward = false): void { + event.stopPropagation(); + const trigger = event.currentTarget; + if (!(trigger instanceof HTMLElement)) { + return; + } + const bounds = trigger.getBoundingClientRect(); + this.menu = { + ...menu, + anchor: { x: upward ? bounds.left : bounds.right, y: upward ? bounds.top : bounds.bottom }, + trigger, + } as SessionDiffMenuData; + } + + private handleMenuAction(action: SessionDiffMenuAction): void { + switch (action.kind) { + case "collapse-all": { + const views = this.diffTask.value?.views ?? []; + this.collapsedPaths = new Set(views.map((view) => view.file.path)); + return; + } + case "expand-all": + this.collapsedPaths = new Set(); + return; + case "toggle-wrap": + this.wrap = !this.wrap; + savePreferences({ split: this.split, wrap: this.wrap }); + return; + case "toggle-split": + this.split = !this.split; + savePreferences({ split: this.split, wrap: this.wrap }); + return; + case "scope": + this.scope = action.value; + return; + case "copy-path": + void copyToClipboard(action.path); + return; + case "open-file": + this.openFile?.(action.path); + return; + case "reveal-file": + this.revealFile?.(action.path); + return; + case "open-editor": + openEditor(action.editor, action.path); + } + } + private renderSummary(result: SessionsDiffResult): TemplateResult { const branchLabel = result.baseRef && result.branch && result.baseRef !== result.branch ? `${result.baseRef} → ${result.branch}` : (result.branch ?? result.baseRef ?? ""); + const syncCommand = + result.root && result.branch + ? `git fetch ${shellArgument(result.root)} ${shellArgument(result.branch)} && git checkout FETCH_HEAD` + : null; return html`
${icons.gitBranch} ${branchLabel} - ${renderDiffStatChips({ added: result.additions, removed: result.deletions })} + ${renderDiffStatChips(totalDiffStat(result.files))} + + ${syncCommand && result.root && result.branch + ? html`` + : nothing} + + +
`; } return html` - ${renderDiffBlock(parsed.lines)} + ${this.split ? renderSessionSplitDiff(this.splitRows(parsed)) : renderDiffBlock(parsed.lines)} ${parsed.truncated ? html`
${t("chat.sessionDiff.truncatedFile")}
` : nothing} `; } - private renderFile(view: FileView): TemplateResult { + private renderFile(view: FileView, result: SessionsDiffResult): TemplateResult { const { file } = view; const collapsed = this.collapsedPaths.has(file.path); + const { directory, name } = splitPath(file.path); + const absPath = result.root ? absolutePath(result.root, file.path) : undefined; + const pathTitle = file.oldPath ? `${file.oldPath} → ${file.path}` : file.path; return html`
- - ${collapsed ? nothing : this.renderFileBody(view)} +
+ + +
+ ${collapsed + ? nothing + : html`
+ ${this.renderFileBody(view)} +
`}
`; } + private scopeTitle(result: SessionsDiffResult): string { + const scope = this.scope; + if (scope.scope === "uncommitted") { + return t("chat.sessionDiff.uncommitted"); + } + if (scope.scope === "commit") { + const commit = result.commits?.find((entry) => entry.sha === scope.commit); + return commit ? `${commit.sha} ${commit.subject}` : scope.commit; + } + return t("chat.sessionDiff.allChanges"); + } + + private renderFooter(result: SessionsDiffResult): TemplateResult { + const branchLabel = result.branch ?? result.baseRef ?? t("chat.sessionDiff.allChanges"); + const label = + result.aheadCount && result.baseRef + ? t("chat.sessionDiff.commitsAhead", { + count: String(result.aheadCount), + base: result.baseRef, + }) + : branchLabel; + return html``; + } + private renderBody(): TemplateResult { if (this.diffTask.status === TaskStatus.ERROR) { const error = this.diffTask.error; @@ -182,18 +447,41 @@ class SessionDiffPanel extends OpenClawLightDomElement { } return html` ${this.renderSummary(result)} - ${result.files.length === 0 - ? html`
${t("chat.sessionDiff.empty")}
` - : views.map((view) => this.renderFile(view))} - ${result.truncated === true - ? html`
${t("chat.sessionDiff.truncatedResult")}
` - : nothing} +
${this.scopeTitle(result)}
+
+ ${result.unavailableReason === "unknown_commit" + ? html`
${t("chat.sessionDiff.unknownCommit")}
` + : result.files.length === 0 + ? html`
${t("chat.sessionDiff.empty")}
` + : views.map((view) => this.renderFile(view, result))} + ${result.truncated === true + ? html`
${t("chat.sessionDiff.truncatedResult")}
` + : nothing} +
+ ${this.renderFooter(result)} `; } override render() { return html` -
${this.renderBody()}
+
+ ${this.renderBody()} + ${this.menu + ? keyed( + this.menu, + html` this.handleMenuAction(action)} + .onClose=${() => { + this.menu = null; + }} + >`, + ) + : nothing} +
`; } } diff --git a/ui/src/pages/chat/components/session-diff-render.ts b/ui/src/pages/chat/components/session-diff-render.ts new file mode 100644 index 000000000000..bbcc8580dbfa --- /dev/null +++ b/ui/src/pages/chat/components/session-diff-render.ts @@ -0,0 +1,44 @@ +import { html } from "lit"; +import { t } from "../../../i18n/index.ts"; +import type { SessionSplitDiffRow } from "../../../lib/chat/session-diff-split.ts"; +import type { DiffLine } from "../../../lib/chat/tool-call-diff.ts"; + +function renderSplitSide(line: DiffLine | undefined, side: "left" | "right") { + const sign = side === "left" ? "-" : "+"; + // Tint only sides that carry a line; a lone add/del keeps its counterpart neutral. + return html`
+ ${line?.lineNo ?? ""} + ${line ? sign : ""} + ${line?.text || (line ? " " : "")} +
`; +} + +export function renderSessionSplitDiff(rows: readonly SessionSplitDiffRow[]) { + return html`
+ ${rows.map((row) => { + if (row.kind === "pair") { + return html`
+ ${renderSplitSide(row.left, "left")} ${renderSplitSide(row.right, "right")} +
`; + } + if (row.line.kind === "skip") { + return html`
+ ${row.line.text || "⋯"} +
`; + } + return html`
+ ${row.line.lineNo ?? ""} + + ${row.line.text || " "} +
`; + })} +
`; +} diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index d5ca757dcf1f..77d1b203d301 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -1923,15 +1923,24 @@ openclaw-session-discussion { .session-diff { display: flex; + min-height: 100%; flex-direction: column; - gap: 10px; + gap: 0; + margin: -8px; + font-size: var(--control-ui-text-sm); } .session-diff__summary { + position: sticky; + top: -16px; + z-index: 3; display: flex; align-items: center; - gap: 8px; - padding-bottom: 2px; + gap: 6px; + min-height: 38px; + padding: 4px 8px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + background: var(--panel); } .session-diff__branch { @@ -1939,7 +1948,7 @@ openclaw-session-discussion { align-items: center; gap: 6px; min-width: 0; - font-weight: 600; + font-weight: 550; font-size: var(--control-ui-text-sm); } @@ -1951,30 +1960,83 @@ openclaw-session-discussion { } .session-diff__branch-label { + max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); } +.session-diff__summary-spacer { + flex: 1; +} + +.session-diff__toolbar-button, +.session-diff__toolbar-icon, .session-diff__refresh { - margin-left: auto; + min-width: 26px; + height: 26px; + min-height: 26px; + padding: 4px; +} + +.session-diff__toolbar-button { + display: inline-flex; + align-items: center; + gap: 2px; + padding-inline: 7px; +} + +.session-diff__toolbar-button svg, +.session-diff__toolbar-icon svg, +.session-diff__refresh svg { + width: 14px; + height: 14px; +} + +.session-diff__section-title { + padding: 11px 8px 6px; + color: var(--muted); + font-size: var(--control-ui-text-xs); + font-weight: 650; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.session-diff__files { + display: flex; + flex-direction: column; + gap: 2px; + padding-bottom: 8px; } .session-diff__file { - border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); - border-radius: var(--radius-sm); + border-block: 1px solid transparent; overflow: hidden; } +.session-diff__file:focus-within, +.session-diff__file:hover { + border-color: color-mix(in srgb, var(--border) 68%, transparent); + background: color-mix(in srgb, var(--secondary) 42%, transparent); +} + .session-diff__file-header { display: flex; align-items: center; - gap: 8px; width: 100%; - padding: 6px 10px; - border: 0; - background: color-mix(in srgb, var(--secondary) 70%, transparent); + min-height: 34px; +} + +.session-diff__file-toggle { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + flex: 1; + padding: 6px 4px 6px 8px; + border: none; + background: transparent; color: var(--text); font: inherit; font-size: var(--control-ui-text-sm); @@ -1982,6 +2044,11 @@ openclaw-session-discussion { cursor: var(--cursor-action); } +.session-diff__file-toggle:focus-visible { + outline: 1px solid var(--accent); + outline-offset: -1px; +} + .session-diff__chevron { display: inline-flex; flex-shrink: 0; @@ -1999,38 +2066,71 @@ openclaw-session-discussion { } .session-diff__status { + display: inline-flex; + align-items: center; + justify-content: center; flex-shrink: 0; - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--muted); + width: 18px; + height: 18px; + border: 1px solid color-mix(in srgb, currentColor 35%, transparent); + border-radius: 4px; + background: color-mix(in srgb, currentColor 10%, transparent); + color: var(--muted); + font-family: var(--mono); + font-size: 10px; + font-weight: 700; } .session-diff__status--added { - background: var(--ok); + color: var(--ok); } .session-diff__status--deleted { - background: var(--destructive); + color: var(--destructive); } .session-diff__status--renamed { - background: var(--accent); + color: var(--accent); } .session-diff__status--modified { - background: var(--warn); + color: var(--warn); } .session-diff__path { + display: flex; + align-items: baseline; + gap: 6px; flex: 1; min-width: 0; - overflow-wrap: anywhere; font-family: var(--mono); + white-space: nowrap; } .session-diff__old-path { + overflow: hidden; + max-width: 110px; + flex-shrink: 1; + text-overflow: ellipsis; color: var(--muted); + font-size: var(--control-ui-text-xs); +} + +.session-diff__filename { + overflow: hidden; + flex-shrink: 0; + max-width: 48%; + text-overflow: ellipsis; + color: var(--text-strong); + font-weight: 550; +} + +.session-diff__directory { + overflow: hidden; + min-width: 0; + text-overflow: ellipsis; + color: var(--muted); + font-size: var(--control-ui-text-xs); } .session-diff__badge { @@ -2042,14 +2142,276 @@ openclaw-session-discussion { font-size: calc(var(--control-ui-text-sm) - 2px); } +.session-diff .chat-diffstat { + gap: 5px; + font-size: var(--control-ui-text-xs); +} + +.session-diff .chat-diffstat__mod { + color: var(--warn); +} + +.session-diff__file-menu { + width: 26px; + min-width: 26px; + height: 26px; + min-height: 26px; + margin-right: 4px; + padding: 4px; + opacity: 0; +} + +.session-diff__file:focus-within .session-diff__file-menu, +.session-diff__file:hover .session-diff__file-menu { + opacity: 1; +} + +.session-diff__file-menu svg { + width: 14px; + height: 14px; +} + +.session-diff__file-body { + content-visibility: auto; +} + .session-diff__file .chat-diff { margin-top: 0; border-radius: 0; max-height: none; + border-top: 1px solid color-mix(in srgb, var(--border) 55%, transparent); + background: color-mix(in srgb, var(--secondary) 45%, transparent); + line-height: 1.45; +} + +.session-diff__file .chat-diff__row { + padding-right: 8px; +} + +.session-diff--wrap .chat-diff__row { + min-width: 0; +} + +.session-diff--wrap .chat-diff__text, +.session-diff--wrap .session-diff-split__text { + min-width: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; } .session-diff__note { - padding: 2px 2px 4px; + padding: 10px; color: var(--muted); font-size: var(--control-ui-text-sm); } + +.session-diff__footer { + position: sticky; + bottom: -16px; + z-index: 3; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 36px; + margin-top: auto; + padding: 7px 10px; + border: 0; + border-top: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + background: var(--panel); + color: var(--muted); + font: inherit; + font-size: var(--control-ui-text-xs); + text-align: left; + cursor: var(--cursor-action); +} + +.session-diff__footer:hover, +.session-diff__footer:focus-visible { + background: color-mix(in srgb, var(--secondary) 62%, var(--panel)); + color: var(--text); +} + +.session-diff__footer svg { + width: 14px; + height: 14px; +} + +.session-diff-split { + overflow: auto; + border-top: 1px solid color-mix(in srgb, var(--border) 55%, transparent); + background: color-mix(in srgb, var(--secondary) 45%, transparent); + font-family: var(--mono); + font-size: 12px; + line-height: 1.45; +} + +.session-diff-split__row--pair { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + min-width: 640px; +} + +.session-diff-split__side, +.session-diff-split__row--context { + display: grid; + grid-template-columns: 42px 16px minmax(max-content, 1fr); + align-items: baseline; + min-width: 0; +} + +.session-diff-split__side--left { + border-right: 1px solid color-mix(in srgb, var(--border) 55%, transparent); +} + +.session-diff-split__side--left.session-diff-split__side--filled { + background: color-mix(in srgb, var(--destructive) 12%, transparent); +} + +.session-diff-split__side--right.session-diff-split__side--filled { + background: color-mix(in srgb, var(--ok) 13%, transparent); +} + +.session-diff-split__row--context { + min-width: max-content; +} + +.session-diff-split__row--skip { + padding-left: 58px; + color: var(--muted); + user-select: none; +} + +.session-diff-split__gutter { + padding-right: 8px; + text-align: right; + color: color-mix(in srgb, var(--muted) 75%, transparent); + user-select: none; +} + +.session-diff-split__sign { + text-align: center; + font-weight: 600; + user-select: none; +} + +.session-diff-split__side--left .session-diff-split__sign { + color: var(--destructive); +} + +.session-diff-split__side--right .session-diff-split__sign { + color: var(--ok); +} + +.session-diff-split__text { + padding-right: 8px; + color: var(--text); + white-space: pre; + tab-size: 4; +} + +openclaw-session-diff-menu[popover] { + width: 0; + height: 0; + margin: 0; + padding: 0; + overflow: visible; + border: 0; + background: transparent; +} + +wa-dropdown.session-diff-menu::part(menu) { + width: min(var(--session-diff-menu-width), calc(100vw - 16px)); + min-width: min(var(--session-diff-menu-width), calc(100vw - 16px)); + max-width: min(var(--session-diff-menu-width), calc(100vw - 16px)); +} + +.session-diff-menu__scope-item .session-menu__text { + display: flex; + align-items: baseline; + gap: 7px; + min-width: 0; +} + +.session-diff-menu__sha { + flex: 0 0 auto; + color: var(--muted); + font-family: var(--mono); + font-size: var(--control-ui-text-xs); +} + +.session-diff-menu__subject { + overflow: hidden; + min-width: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-diff-menu__head { + flex: 0 0 auto; + padding: 0 4px; + border-radius: 3px; + background: color-mix(in srgb, var(--accent) 15%, transparent); + color: var(--accent); + font-size: 9px; + font-weight: 700; +} + +.session-diff-menu__merge-base { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + gap: 7px; + padding: 6px 8px; + color: var(--muted); + font-size: var(--control-ui-text-xs); +} + +.session-diff-menu__sync { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; +} + +.session-diff-menu__sync p { + margin: 0; + color: var(--muted); + font-size: var(--control-ui-text-xs); + line-height: 1.4; +} + +.session-diff-menu__copy-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 6px; +} + +.session-diff-menu__copy-label { + grid-column: 1 / -1; + color: var(--muted); + font-size: var(--control-ui-text-xs); +} + +.session-diff-menu__copy-row code { + overflow: hidden; + padding: 4px 6px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--secondary) 70%, transparent); + color: var(--text); + font-size: var(--control-ui-text-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-diff-menu__copy-row.is-command code { + white-space: normal; + overflow-wrap: anywhere; +} + +.session-diff-menu__copy-row .chat-copy-btn { + width: 28px; + min-width: 28px; + height: 28px; + padding: 5px; +}