mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat: show cloud workspace conflicts in the Control UI (#111329)
* feat(ui): surface cloud workspace conflicts with staged-ref guidance * fix(ui): satisfy workspace conflict CI checks * fix(ui): reject terminal controls in conflict paths * chore: keep release notes out of the PR * fix(ui): satisfy conflict path lint * fix(ui): retain cloud conflicts after reclaim * fix(ui): keep child conflict badges visible * test(ui): cover cloud conflict recovery in browser * fix(ci): rotate poisoned dependency snapshot * fix(ui): keep nested cloud conflicts discoverable * docs(changelog): note cloud conflict UI * fix(ui): restore conflict UI budget headroom
This commit is contained in:
@@ -117,13 +117,13 @@ runs:
|
||||
if: inputs.sticky-disk == 'true'
|
||||
uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # main (post-v1.4.0 hot-attach fix)
|
||||
with:
|
||||
# One stable disk per Node line. v5 starts a fresh lineage for the
|
||||
# preflight-serialized writer after Blacksmith acknowledged repeated v4
|
||||
# One stable disk per Node line. v6 starts a fresh lineage for the
|
||||
# preflight-serialized writer after Blacksmith acknowledged repeated v5
|
||||
# commits but kept restoring its original snapshot. The v2 per-PR/per-manifest-hash keys
|
||||
# saturated Blacksmith's installation-wide sticky-disk budget. Install
|
||||
# inputs, runner platform, and the exact Node patch live in the runtime
|
||||
# marker below, so changes refresh this disk in place.
|
||||
key: ${{ github.repository }}-node-deps-bind-v5-${{ inputs.node-version }}
|
||||
key: ${{ github.repository }}-node-deps-bind-v6-${{ inputs.node-version }}
|
||||
path: /var/tmp/openclaw-node-deps
|
||||
# Single semantic writer: only the designated trusted-push job may
|
||||
# commit, so pull_request clones stay read-only. Like every sticky
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
"architecture": "amd64",
|
||||
"region": "eu-west"
|
||||
},
|
||||
{
|
||||
"key": "openclaw/openclaw-node-deps-bind-v5-24.x",
|
||||
"architecture": "amd64",
|
||||
"region": "eu-west"
|
||||
},
|
||||
{
|
||||
"key": "openclaw/openclaw-vitest-fs-v2-protected-Linux-X64-node-24.x",
|
||||
"architecture": "amd64",
|
||||
|
||||
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- **Control UI cloud workspace conflicts:** surface staged-ref guidance, bounded conflicted paths, structured transcript events, and sidebar attention for cloud worker results that kept local versions.
|
||||
- **Control UI update recovery:** the "A new version is available" Reload button now waits out the gateway restart that stranded the chunk and reloads as soon as it answers, instead of silently doing nothing and leaving a manual hard reload as the only way out.
|
||||
- **Control UI sender identity polish:** attributed user messages show the author's real avatar in an always-visible gutter on identity-resolving gateways, sender labels drop the opaque profile-UUID suffix (new and historical transcripts), and profile-id senders resolve avatars through the canonical gateway route.
|
||||
- **Control UI who's-online roster:** click the sidebar footer facepile to open a scrollable roster of everyone online, showing each person's avatar, name, and email with your own entry pinned first.
|
||||
|
||||
@@ -96,7 +96,11 @@ openclaw gateway call sessions.dispatch \
|
||||
|
||||
`sessions.dispatch` closes local turn admission, drains active work, provisions the lease, runs setup, bootstraps OpenClaw, syncs the workspace, and returns once the placement reaches `active` worker ownership. Budget several minutes for the first dispatch; leases and installs are cached where the provider supports it. After that, talk to the session as usual — turns route to the worker automatically.
|
||||
|
||||
Completed worker turns reconcile eligible, size-bounded workspace files back into the session's managed worktree before the turn claim is released. The terminal worker event creates a durable pending-result fence before it is acknowledged, so Gateway restart recovery pulls the remote workspace back before stale-turn cleanup can destroy its owner. Reconciliation authenticates the worker manifest and stops on local divergence instead of overwriting either side. Before changing files, the Gateway stores a bounded rollback journal in its SQLite state database; a retry recovers that journal after an interrupted Gateway process. Workspace results use Git file semantics: regular files, executable bits, symlinks, additions, changes, and deletions are retained, while empty directories and other directory modes are not. Remote commit objects are not retained; the resulting file changes remain in the managed worktree for normal review and commit.
|
||||
Completed worker turns reconcile eligible, size-bounded workspace files back into the session's managed worktree before the turn claim is released. The terminal worker event creates a durable pending-result fence before it is acknowledged. The Gateway then stages the complete cloud result as a Git ref under `refs/openclaw/worker-results/` before applying it, so the cloud version remains recoverable even if the Gateway stops during the apply. Workspace results use Git file semantics: regular files, executable bits, symlinks, additions, changes, and deletions are retained, while empty directories and other directory modes are not. The resulting file changes remain in the managed worktree for normal review and commit.
|
||||
|
||||
Apply uses the dispatch-time manifest as the merge base. Cloud-only changes are applied, local-only changes stay in place, and paths changed on both sides use a three-way keep-local policy. A conflicted turn still finishes: the transcript reports the bounded path summary and staged result ref, the placement exposes the same conflict for the Control UI, and non-conflicting cloud changes remain applied. The notice includes `git show <ref>:<path>` to inspect a present cloud file and a top-level literal-pathspec `git checkout <ref> -- <path>` command to take it from any workspace directory. Run the commands in Bash or zsh (Git Bash on Windows). If inspect says the path does not exist, the cloud result deleted it; verify and remove the retained local path manually. If checkout reports a file/directory obstruction, move or remove the blocking local path and retry. If the staged ref itself is gone, treat the notice as stale and do not change the local path. Conflicted staged refs remain available after the normal turn fence is released; a later clean result clears the notice and retires the old ref, while explicit fence removal is the final cleanup boundary.
|
||||
|
||||
While a fenced result is still reconciling, a new turn waits up to 15 seconds for the prior claim to release. If it is still busy, the turn fails with an actionable “previous cloud turn's workspace result is still reconciling” message and can be retried shortly. On restart, recovery discovers pending and staged results before stale-claim cleanup, completes or retries their local apply, and reclaims dead environments only after preserving the result. The bounded SQLite rollback journal makes an interrupted filesystem apply recoverable without replaying already accepted mutations.
|
||||
|
||||
When the work is complete and no turn is running, open the session menu and choose **Stop cloud worker…**. The Gateway performs one final workspace reconciliation before it destroys the environment. A placement already in `draining` or `reconciling` is finishing teardown; wait for its badge to become `reclaimed` before deleting the session.
|
||||
|
||||
@@ -110,7 +114,7 @@ openclaw gateway call sessions.reclaim \
|
||||
--params '{"key":"agent:main:big-refactor"}'
|
||||
```
|
||||
|
||||
Placement moves through a durable state machine (`local → requested → provisioning → syncing → starting → active`), so a Gateway restart mid-dispatch reconciles instead of leaking machines. A failed model turn keeps the active placement available for a retry. If inbound workspace reconciliation fails, the worker also stays active so the operator can resolve the local conflict and retry without losing the remote result; lifecycle failures instead move the placement to an error or reclaimed state and preserve their diagnostic tail.
|
||||
Placement moves through a durable state machine (`local → requested → provisioning → syncing → starting → active`), so a Gateway restart mid-dispatch reconciles instead of leaking machines. A failed model turn keeps the active placement available for a retry. Workspace path conflicts keep the local version, apply the rest of the cloud result, and preserve the staged cloud ref for inspection; other reconciliation or lifecycle failures retain their durable recovery fence and diagnostic tail until recovery can safely retry or reclaim the environment.
|
||||
|
||||
## Security model
|
||||
|
||||
@@ -129,6 +133,8 @@ Placement moves through a durable state machine (`local → requested → provis
|
||||
- **AWS instance-role attestation fails** — clear `aws.instanceProfile` (and `CRABBOX_AWS_INSTANCE_PROFILE`, if set). Install Crabbox 0.38.1 or newer; older binaries do not expose the authoritative `providerMetadata.instanceProfileAttached` contract required for AWS admission.
|
||||
- **Dispatch fails with a provider error** — the placement record and `environments.list` keep the last error, including the setup/bootstrap stderr tail. Boxes are destroyed on failure, so that tail is the primary forensic.
|
||||
- **Client timeout while dispatching** — `openclaw gateway call` defaults to a 10s timeout; pass `--timeout` generously (dispatch keeps running server-side either way, and a retry while provisioning is rejected with `session cannot dispatch from placement provisioning`).
|
||||
- **Cloud workspace conflict notice** — the turn completed and kept the local version of each listed path. Use the staged-ref commands in the notice to inspect or take the cloud version; no retry is required for the non-conflicting changes, which are already applied.
|
||||
- **“The previous cloud turn's workspace result is still reconciling”** — the Gateway waited briefly for the prior result's durable fence and could not acquire the session claim. Wait for reconciliation to finish, then retry the turn; restarting the Gateway is safe because recovery preserves staged results before reclaiming a dead worker.
|
||||
- **Lease housekeeping** — `crabbox list --provider <backend>` shows live leases; `crabbox stop --provider <backend> --id <lease>` releases one manually. Idle leases expire on the profile's `idleTimeout`.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -12,11 +12,11 @@ const KIB = 1024;
|
||||
export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze({
|
||||
startupJsRequests: 18,
|
||||
startupCssRequests: 1,
|
||||
// 313 KiB accompanies the widget sandbox-origin diagnostics (2026-07): the
|
||||
// startup catalog gained the operator hint strings and main sat within
|
||||
// ~0.1 KiB of the previous ceiling, failing source builds whose zlib packs
|
||||
// slightly worse than CI's. One KiB restores explicit headroom.
|
||||
startupJsGzipBytes: 313 * KIB,
|
||||
// 314 KiB accompanies cloud-workspace conflict recovery (2026-07): the live
|
||||
// notice and sidebar attention must be available on initial chat render, and
|
||||
// their bounded recovery copy exhausted the previous ceiling after rebasing.
|
||||
// One KiB restores explicit headroom without changing the request budget.
|
||||
startupJsGzipBytes: 314 * KIB,
|
||||
// 45 KiB CSS ceilings maintainer-approved 2026-07 alongside the interleaved
|
||||
// sidebar zone styling; headroom over the ~36.5 KiB post-diet baseline.
|
||||
startupCssGzipBytes: 45 * KIB,
|
||||
|
||||
@@ -31,6 +31,10 @@ import {
|
||||
isSuppressedControlReplyText,
|
||||
stripSuppressedControlReplyToken,
|
||||
} from "./control-reply-text.js";
|
||||
import {
|
||||
projectWorkspaceResultConflict,
|
||||
WORKSPACE_CONFLICT_TRANSCRIPT_TYPE,
|
||||
} from "./worker-environments/workspace-conflicts.js";
|
||||
|
||||
export const DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS = 8_000;
|
||||
|
||||
@@ -582,6 +586,39 @@ function sanitizeUsage(raw: unknown): Record<string, number> | undefined {
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function projectWorkspaceConflictDetails(
|
||||
entry: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (entry.role !== "custom" || entry.customType !== WORKSPACE_CONFLICT_TRANSCRIPT_TYPE) {
|
||||
return undefined;
|
||||
}
|
||||
const details = readRecord(entry.details);
|
||||
if (
|
||||
!details ||
|
||||
!Array.isArray(details.paths) ||
|
||||
details.paths.length === 0 ||
|
||||
!details.paths.every(
|
||||
(entryPath): entryPath is string => typeof entryPath === "string" && entryPath.length > 0,
|
||||
) ||
|
||||
typeof details.stagedResultRef !== "string" ||
|
||||
!/^refs\/openclaw\/worker-results\/[A-Za-z0-9-]+$/u.test(details.stagedResultRef) ||
|
||||
(details.totalCount !== undefined &&
|
||||
(!Number.isSafeInteger(details.totalCount) ||
|
||||
(details.totalCount as number) < details.paths.length))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return projectWorkspaceResultConflict(
|
||||
details.paths,
|
||||
details.stagedResultRef,
|
||||
details.totalCount as number | undefined,
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeChatHistoryMessage(
|
||||
message: unknown,
|
||||
maxChars: number = DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS,
|
||||
@@ -603,9 +640,11 @@ function sanitizeChatHistoryMessage(
|
||||
typeof entry.tool_call_id === "string";
|
||||
|
||||
if ("details" in entry) {
|
||||
const projectedDetails = messageHasToolResultShape(entry)
|
||||
? projectToolResultDetails(entry.details, maxChars)
|
||||
: undefined;
|
||||
const projectedDetails =
|
||||
projectWorkspaceConflictDetails(entry) ??
|
||||
(messageHasToolResultShape(entry)
|
||||
? projectToolResultDetails(entry.details, maxChars)
|
||||
: undefined);
|
||||
if (projectedDetails) {
|
||||
entry.details = projectedDetails;
|
||||
} else {
|
||||
|
||||
@@ -852,6 +852,37 @@ describe("injectTimestamp", () => {
|
||||
});
|
||||
|
||||
describe("sanitizeChatHistoryMessages", () => {
|
||||
it("preserves bounded cloud workspace conflict details for Control UI history", () => {
|
||||
const result = sanitizeChatHistoryMessages([
|
||||
{
|
||||
role: "custom",
|
||||
customType: "cloud-workspace-conflict",
|
||||
content: "Cloud result applied with conflicts.",
|
||||
details: {
|
||||
paths: ["src/local.ts", "ui/src/app.ts"],
|
||||
stagedResultRef: "refs/openclaw/worker-results/claim-1",
|
||||
totalCount: 3,
|
||||
internal: "discard",
|
||||
},
|
||||
timestamp: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "custom",
|
||||
customType: "cloud-workspace-conflict",
|
||||
content: "Cloud result applied with conflicts.",
|
||||
details: {
|
||||
paths: ["src/local.ts", "ui/src/app.ts"],
|
||||
stagedResultRef: "refs/openclaw/worker-results/claim-1",
|
||||
totalCount: 3,
|
||||
},
|
||||
timestamp: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("truncates display text without splitting surrogate pairs", () => {
|
||||
const prefix = "a".repeat(7);
|
||||
const result = sanitizeChatHistoryMessages(
|
||||
|
||||
@@ -2093,7 +2093,7 @@ describe("ci workflow guards", () => {
|
||||
// per-PR/per-manifest-hash keys saturated that cap. Install inputs and exact
|
||||
// runtime patches belong in the marker, not the backing-disk key.
|
||||
expect(mountStep.with.key).toBe(
|
||||
"${{ github.repository }}-node-deps-bind-v5-${{ inputs.node-version }}",
|
||||
"${{ github.repository }}-node-deps-bind-v6-${{ inputs.node-version }}",
|
||||
);
|
||||
expect(mountStep.with.commit).toBe(
|
||||
"${{ inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }}",
|
||||
|
||||
@@ -164,6 +164,13 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
|
||||
acpSession: isAcpSessionKey(row.key),
|
||||
worktreeId: row.worktree?.id,
|
||||
placementState: row.placement?.state,
|
||||
workspaceConflictCount:
|
||||
row.placement && "workspaceResultConflict" in row.placement
|
||||
? Math.max(
|
||||
row.placement.workspaceResultConflict?.paths.length ?? 0,
|
||||
row.placement.workspaceResultConflict?.totalCount ?? 0,
|
||||
) || undefined
|
||||
: undefined,
|
||||
cloudWorkerActive: isStoppableCloudWorkerPlacement(row.placement),
|
||||
hasAutomation: row.hasAutomation === true,
|
||||
hasOpenPullRequest: context?.sessions.hasOpenPullRequest?.(row.key) === true,
|
||||
|
||||
@@ -89,6 +89,13 @@ export function projectSessionTree(params: {
|
||||
child.failedChildCount,
|
||||
0,
|
||||
);
|
||||
// Conflict attention is transitive: a collapsed parent must expose staged
|
||||
// cloud results held by descendants or the recovery signal disappears.
|
||||
const workspaceConflictCount = Math.min(
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
(projected.workspaceConflictCount ?? 0) +
|
||||
children.reduce((count, child) => count + (child.workspaceConflictCount ?? 0), 0),
|
||||
);
|
||||
const unloadedChildKeys = childSessionKeys.filter((key) => !rowsByKey.has(key));
|
||||
// Only direct unloaded children can match: parents carry their keys, but not grandchildren's.
|
||||
// Grandchildren join the normal transitive fold after their branch is materialized.
|
||||
@@ -122,6 +129,7 @@ export function projectSessionTree(params: {
|
||||
containsActiveDescendant: children.some(
|
||||
(child) => child.active || child.visuallyActive || child.containsActiveDescendant,
|
||||
),
|
||||
workspaceConflictCount: workspaceConflictCount || undefined,
|
||||
runningChildCount,
|
||||
failedChildCount,
|
||||
};
|
||||
|
||||
@@ -68,6 +68,7 @@ export type SidebarRecentSession = {
|
||||
acpSession?: boolean;
|
||||
worktreeId?: string;
|
||||
placementState?: SessionPlacementState;
|
||||
workspaceConflictCount?: number;
|
||||
cloudWorkerActive: boolean;
|
||||
hasAutomation: boolean;
|
||||
hasOpenPullRequest: boolean;
|
||||
|
||||
@@ -17,11 +17,12 @@ afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function renderBadges(placementState?: SessionPlacementState) {
|
||||
function renderBadges(placementState?: SessionPlacementState, workspaceConflictCount?: number) {
|
||||
render(
|
||||
renderSessionRowBadges({
|
||||
hasAutomation: false,
|
||||
placementState,
|
||||
workspaceConflictCount,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
@@ -114,4 +115,53 @@ describe("session row placement badges", () => {
|
||||
expect(container.querySelector(".session-row-badge--approval")).not.toBeNull();
|
||||
expect(container.querySelector(".session-row-badge--cloud")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps conflict attention visible for child sessions", () => {
|
||||
render(
|
||||
renderSessionRowBadges({
|
||||
isChild: true,
|
||||
hasAutomation: false,
|
||||
placementState: "reclaimed",
|
||||
workspaceConflictCount: 2,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const badge = container.querySelector<HTMLElement>(".session-row-badge--cloud");
|
||||
expect(badge?.dataset.placementState).toBe("reclaimed");
|
||||
expect(badge?.dataset.workspaceConflicts).toBe("2");
|
||||
expect(container.querySelectorAll(".session-row-badge")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses the existing cloud badge to call out workspace conflicts", () => {
|
||||
renderBadges("active", 3);
|
||||
|
||||
const badge = container.querySelector<HTMLElement>(".session-row-badge--cloud");
|
||||
expect(badge?.dataset.workspaceConflicts).toBe("3");
|
||||
expect(badge?.getAttribute("title")).toBe("Cloud worker: active · 3 workspace conflicts");
|
||||
expect(container.querySelectorAll(".session-row-badge")).toHaveLength(1);
|
||||
|
||||
renderBadges("active", 1);
|
||||
expect(container.querySelector(".session-row-badge--cloud")?.getAttribute("title")).toBe(
|
||||
"Cloud worker: active · 1 workspace conflict",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps retained workspace conflicts visible after reclaim", () => {
|
||||
renderBadges("reclaimed", 2);
|
||||
|
||||
const badge = container.querySelector<HTMLElement>(".session-row-badge--cloud");
|
||||
expect(badge?.dataset.placementState).toBe("reclaimed");
|
||||
expect(badge?.dataset.workspaceConflicts).toBe("2");
|
||||
expect(badge?.getAttribute("title")).toBe("Cloud worker: reclaimed · 2 workspace conflicts");
|
||||
});
|
||||
|
||||
it("renders descendant conflict attention without claiming a parent placement state", () => {
|
||||
renderBadges(undefined, 2);
|
||||
|
||||
const badge = container.querySelector<HTMLElement>(".session-row-badge--cloud");
|
||||
expect(badge?.dataset.placementState).toBeUndefined();
|
||||
expect(badge?.dataset.workspaceConflicts).toBe("2");
|
||||
expect(badge?.getAttribute("title")).toBe("Cloud worker children: 2 workspace conflicts");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,18 +22,47 @@ export function renderSessionRowBadges(params: {
|
||||
hasOpenPullRequest?: boolean;
|
||||
hasApproval?: boolean;
|
||||
placementState?: SessionPlacementState;
|
||||
workspaceConflictCount?: number;
|
||||
}) {
|
||||
const hasAutomation = !params.isChild && params.hasAutomation;
|
||||
const placementState = params.isChild ? undefined : params.placementState;
|
||||
const cloudPlacementState = isCloudWorkerPlacementState(placementState)
|
||||
? placementState
|
||||
: undefined;
|
||||
if (!hasAutomation && !params.hasOpenPullRequest && !params.hasApproval && !cloudPlacementState) {
|
||||
const workspaceConflictCount = Math.max(0, Math.floor(params.workspaceConflictCount ?? 0));
|
||||
// Child rows suppress ordinary placement chrome, but a retained conflict must stay discoverable.
|
||||
const conflictPlacementState = workspaceConflictCount > 0 ? params.placementState : undefined;
|
||||
const displayedPlacementState = cloudPlacementState ?? conflictPlacementState;
|
||||
const hasWorkspaceConflict = workspaceConflictCount > 0;
|
||||
if (
|
||||
!hasAutomation &&
|
||||
!params.hasOpenPullRequest &&
|
||||
!params.hasApproval &&
|
||||
!displayedPlacementState &&
|
||||
!hasWorkspaceConflict
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
const cloudLabel = cloudPlacementState
|
||||
? t("sessionsView.cloudWorkerPlacement", { state: cloudPlacementState })
|
||||
: "";
|
||||
const cloudLabel = hasWorkspaceConflict
|
||||
? displayedPlacementState
|
||||
? t(
|
||||
workspaceConflictCount === 1
|
||||
? "sessionsView.cloudWorkerPlacementConflict"
|
||||
: "sessionsView.cloudWorkerPlacementConflicts",
|
||||
{
|
||||
state: displayedPlacementState,
|
||||
count: String(workspaceConflictCount),
|
||||
},
|
||||
)
|
||||
: t(
|
||||
workspaceConflictCount === 1
|
||||
? "sessionsView.cloudWorkerDescendantConflict"
|
||||
: "sessionsView.cloudWorkerDescendantConflicts",
|
||||
{ count: String(workspaceConflictCount) },
|
||||
)
|
||||
: displayedPlacementState
|
||||
? t("sessionsView.cloudWorkerPlacement", { state: displayedPlacementState })
|
||||
: "";
|
||||
return html`<span class="session-row-badges">
|
||||
${hasAutomation
|
||||
? html`<span
|
||||
@@ -62,10 +91,13 @@ export function renderSessionRowBadges(params: {
|
||||
>${icons.alertTriangle}</span
|
||||
>`
|
||||
: nothing}
|
||||
${cloudPlacementState
|
||||
${displayedPlacementState || hasWorkspaceConflict
|
||||
? html`<span
|
||||
class="session-row-badge session-row-badge--cloud"
|
||||
data-placement-state=${cloudPlacementState}
|
||||
data-placement-state=${displayedPlacementState ?? nothing}
|
||||
data-workspace-conflicts=${hasWorkspaceConflict
|
||||
? String(workspaceConflictCount)
|
||||
: nothing}
|
||||
role="img"
|
||||
aria-label=${cloudLabel}
|
||||
title=${cloudLabel}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// Control UI browser proof covers the cloud-workspace conflict recovery lifecycle.
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium, type Browser } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
installMockGateway,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
|
||||
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
||||
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
|
||||
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
||||
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
||||
const proofDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
||||
const sessionKey = "agent:main:conflict-proof";
|
||||
|
||||
const conflict = {
|
||||
paths: ["src/local.ts", "ui/src/app.ts"],
|
||||
stagedResultRef: "refs/openclaw/worker-results/claim-proof",
|
||||
totalCount: 2,
|
||||
};
|
||||
|
||||
function sessionsList(includeConflict: boolean) {
|
||||
const now = Date.now();
|
||||
const label = includeConflict ? "Cloud conflict proof" : "Cloud conflict cleared";
|
||||
return {
|
||||
count: 1,
|
||||
defaults: {
|
||||
contextTokens: null,
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
},
|
||||
path: "",
|
||||
sessions: [
|
||||
{
|
||||
contextTokens: null,
|
||||
displayName: label,
|
||||
hasActiveRun: false,
|
||||
key: sessionKey,
|
||||
kind: "direct",
|
||||
label,
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
placement: {
|
||||
state: "reclaimed",
|
||||
generation: 1,
|
||||
createdAtMs: now - 10_000,
|
||||
updatedAtMs: now,
|
||||
stateChangedAtMs: now - 1_000,
|
||||
...(includeConflict ? { workspaceResultConflict: conflict } : {}),
|
||||
},
|
||||
status: "done",
|
||||
totalTokens: 0,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
ts: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function capture(page: import("playwright").Page, name: string): Promise<void> {
|
||||
if (!proofDir) {
|
||||
return;
|
||||
}
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
fullPage: true,
|
||||
path: path.join(proofDir, name),
|
||||
});
|
||||
}
|
||||
|
||||
let browser: Browser;
|
||||
let server: ControlUiE2eServer;
|
||||
|
||||
describeControlUiE2e("Control UI cloud workspace conflict recovery", () => {
|
||||
beforeAll(async () => {
|
||||
if (!chromiumAvailable) {
|
||||
throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`);
|
||||
}
|
||||
if (proofDir) {
|
||||
await mkdir(proofDir, { recursive: true });
|
||||
}
|
||||
server = await startControlUiE2eServer();
|
||||
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("shows, dismisses, and reloads durable conflict recovery guidance", async () => {
|
||||
const context = await browser.newContext({
|
||||
colorScheme: "dark",
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1440 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
historyMessages: [
|
||||
{
|
||||
role: "custom",
|
||||
customType: "cloud-workspace-conflict",
|
||||
content: "Cloud result applied with 2 conflicts.",
|
||||
details: conflict,
|
||||
timestamp: Date.now() - 500,
|
||||
},
|
||||
],
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsList(true),
|
||||
},
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await page.goto(
|
||||
`${server.baseUrl}chat?session=${encodeURIComponent(sessionKey)}`,
|
||||
);
|
||||
expect(response?.status()).toBe(200);
|
||||
|
||||
const notice = page.locator(".chat-workspace-conflict-notice");
|
||||
const sessionRow = page.locator(`[data-session-key="${sessionKey}"]`);
|
||||
await notice.waitFor({ timeout: 10_000 });
|
||||
await sessionRow.locator('.session-row-badge--cloud[data-workspace-conflicts="2"]').waitFor();
|
||||
const historyCard = page.locator(".chat-workspace-conflict-event");
|
||||
await historyCard.waitFor();
|
||||
expect(await notice.textContent()).toContain("2 cloud workspace conflicts");
|
||||
expect(await historyCard.textContent()).toContain(conflict.stagedResultRef);
|
||||
await capture(page, "01-live-conflict.png");
|
||||
|
||||
await page.getByRole("button", { name: "Dismiss workspace conflict notice" }).click();
|
||||
await notice.waitFor({ state: "detached" });
|
||||
await historyCard.waitFor();
|
||||
await capture(page, "02-dismissed-live-notice.png");
|
||||
|
||||
await gateway.setMethodResponse("sessions.list", sessionsList(false));
|
||||
await page.reload();
|
||||
await page.locator(".chat-workspace-conflict-event").waitFor({ timeout: 10_000 });
|
||||
await sessionRow.getByText("Cloud conflict cleared", { exact: true }).waitFor();
|
||||
expect(await page.locator(".chat-workspace-conflict-notice").count()).toBe(0);
|
||||
expect(await sessionRow.locator(".session-row-badge--cloud").count()).toBe(0);
|
||||
expect(await page.locator(".chat-workspace-conflict-event").textContent()).toContain(
|
||||
conflict.stagedResultRef,
|
||||
);
|
||||
await capture(page, "03-reloaded-durable-history.png");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -653,6 +653,10 @@ export const en: TranslationMap = {
|
||||
worktreeSession: "Worktree thread",
|
||||
automationAttached: "Automation attached",
|
||||
cloudWorkerPlacement: "Cloud worker: {state}",
|
||||
cloudWorkerPlacementConflict: "Cloud worker: {state} · 1 workspace conflict",
|
||||
cloudWorkerPlacementConflicts: "Cloud worker: {state} · {count} workspace conflicts",
|
||||
cloudWorkerDescendantConflict: "Cloud worker child: 1 workspace conflict",
|
||||
cloudWorkerDescendantConflicts: "Cloud worker children: {count} workspace conflicts",
|
||||
renameSession: "Rename thread",
|
||||
renameSessionPrompt: "Rename thread",
|
||||
renameSessionMenu: "Rename…",
|
||||
@@ -3746,6 +3750,29 @@ export const en: TranslationMap = {
|
||||
exitFocusMode: "Exit focus mode",
|
||||
scrollToLatest: "Scroll to latest",
|
||||
},
|
||||
workspaceConflict: {
|
||||
titleOne: "1 cloud workspace conflict",
|
||||
titleMany: "{count} cloud workspace conflicts",
|
||||
description:
|
||||
"OpenClaw kept your local versions and applied the other cloud changes. Inspect the staged result or take its version for a conflicted path.",
|
||||
morePaths: "+{count} more paths",
|
||||
stagedResult: "Staged cloud result",
|
||||
copyStagedResult: "Copy staged result ref",
|
||||
inspectCloud: "Inspect the first cloud version",
|
||||
takeCloud: "Take the first cloud version",
|
||||
copyInspectCommand: "Copy cloud inspect command",
|
||||
copyTakeCommand: "Copy take-cloud command",
|
||||
commandHelp:
|
||||
"Run these in Bash or zsh (Git Bash on Windows). If inspect says the path does not exist, the cloud deleted it; verify and remove the local path manually. If checkout reports a file/directory conflict, move or remove the blocking local path, then retry. If the staged ref is missing, the notice is stale; do not change the local path.",
|
||||
commandsUnavailable:
|
||||
"This filename contains terminal control characters, so OpenClaw will not build a copyable shell command for it. Inspect the staged ref directly and enter the path manually with care.",
|
||||
dismiss: "Dismiss workspace conflict notice",
|
||||
eventSender: "Cloud workspace",
|
||||
eventTitleOne: "Cloud result applied with 1 conflict",
|
||||
eventTitleMany: "Cloud result applied with {count} conflicts",
|
||||
eventDescription:
|
||||
"Local versions were kept for these paths; other cloud changes were applied.",
|
||||
},
|
||||
compaction: {
|
||||
label: "Compacted history",
|
||||
savedTokens: "saved {count} tokens",
|
||||
|
||||
@@ -233,6 +233,7 @@ import {
|
||||
} from "./session-message-cache.ts";
|
||||
import { reconcileWaitingApprovalsFromSnapshot } from "./tool-stream.ts";
|
||||
import { configureToolTitleFetcher } from "./tool-titles.ts";
|
||||
import { workspaceResultConflictFromPlacement } from "./workspace-conflict.ts";
|
||||
|
||||
type ChatPageContext = ApplicationContext;
|
||||
type PaneSessionChangeOptions = { replace?: boolean };
|
||||
@@ -480,6 +481,7 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
private sessionPullRequestsRequestVersion = 0;
|
||||
private sessionPullRequestsExpanded = false;
|
||||
private dismissedSessionPullRequestIds: ReadonlySet<string> = new Set();
|
||||
private readonly dismissedWorkspaceConflictRefs = new Map<string, string>();
|
||||
@litState() private catalogMessages: unknown[] = [];
|
||||
@litState() private catalogLoading = false;
|
||||
@litState() private loadingOlder = false;
|
||||
@@ -3091,6 +3093,13 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
const selectedSession = state.sessionsResult?.sessions.find((row) =>
|
||||
areUiSessionKeysEquivalent(row.key, state.sessionKey),
|
||||
);
|
||||
const workspaceConflict = workspaceResultConflictFromPlacement(selectedSession?.placement);
|
||||
const visibleWorkspaceConflict =
|
||||
workspaceConflict &&
|
||||
this.dismissedWorkspaceConflictRefs.get(selectedSession?.key ?? state.sessionKey) !==
|
||||
workspaceConflict.stagedResultRef
|
||||
? workspaceConflict
|
||||
: undefined;
|
||||
const board = this.resolveBoardView();
|
||||
const runtimeConfigState = this.context.runtimeConfig.state;
|
||||
const configSnapshot = runtimeConfigState.configSnapshot;
|
||||
@@ -3247,6 +3256,17 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
onApprovalDecision: overlays
|
||||
? (approvalId, decision) => overlays.decideApproval(decision, approvalId)
|
||||
: undefined,
|
||||
workspaceConflict: visibleWorkspaceConflict,
|
||||
onDismissWorkspaceConflict:
|
||||
visibleWorkspaceConflict && selectedSession
|
||||
? () => {
|
||||
this.dismissedWorkspaceConflictRefs.set(
|
||||
selectedSession.key,
|
||||
visibleWorkspaceConflict.stagedResultRef,
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
: undefined,
|
||||
sessions: state.sessionsResult,
|
||||
sessionHost: {
|
||||
assistantAgentId: state.assistantAgentId,
|
||||
|
||||
@@ -45,6 +45,10 @@ import {
|
||||
} from "./components/chat-thread.ts";
|
||||
import { renderWelcomeState } from "./components/chat-welcome.ts";
|
||||
import { RealtimeTalkLevelSignal } from "./realtime-talk-level.ts";
|
||||
import {
|
||||
workspaceConflictPathForDisplay,
|
||||
workspaceResultConflictFromTranscript,
|
||||
} from "./workspace-conflict.ts";
|
||||
|
||||
const registeredAttachmentPayloads = new Map<
|
||||
string,
|
||||
@@ -751,6 +755,112 @@ describe("chat compaction divider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloud workspace conflict notice", () => {
|
||||
const conflict = {
|
||||
paths: [
|
||||
"src/[path]-1.ts",
|
||||
"src/path-2.ts",
|
||||
"src/path-3.ts",
|
||||
"src/path-4.ts",
|
||||
"src/path-5.ts",
|
||||
"src/path-6.ts",
|
||||
],
|
||||
stagedResultRef: "refs/openclaw/worker-results/claim-123",
|
||||
totalCount: 9,
|
||||
};
|
||||
|
||||
it("bounds paths and renders copyable staged-ref guidance", () => {
|
||||
const onDismissWorkspaceConflict = vi.fn();
|
||||
const container = renderChatView({
|
||||
workspaceConflict: conflict,
|
||||
onDismissWorkspaceConflict,
|
||||
});
|
||||
|
||||
const notice = requireElement(
|
||||
container,
|
||||
".chat-workspace-conflict-notice",
|
||||
"workspace conflict notice",
|
||||
);
|
||||
expect(notice.textContent).toContain("9 cloud workspace conflicts");
|
||||
expect(notice.querySelectorAll(".chat-workspace-conflict-paths li")).toHaveLength(5);
|
||||
expect(notice.textContent).toContain("+4 more paths");
|
||||
expect(notice.textContent).toContain(conflict.stagedResultRef);
|
||||
expect(notice.textContent).toContain("Git Bash on Windows");
|
||||
expect(notice.textContent).toContain("file/directory conflict");
|
||||
expect(notice.textContent).toContain("cloud deleted it");
|
||||
expect(notice.textContent).toContain("staged ref is missing");
|
||||
|
||||
const commands = [...notice.querySelectorAll(".chat-workspace-conflict-commands code")].map(
|
||||
(element) => element.textContent,
|
||||
);
|
||||
expect(commands).toEqual([
|
||||
"git show 'refs/openclaw/worker-results/claim-123:src/[path]-1.ts'",
|
||||
"git checkout 'refs/openclaw/worker-results/claim-123' -- ':(top,literal)src/[path]-1.ts'",
|
||||
]);
|
||||
expect(
|
||||
notice.querySelector<HTMLButtonElement>('[aria-label="Copy cloud inspect command"]'),
|
||||
).toBeInstanceOf(HTMLButtonElement);
|
||||
expect(
|
||||
notice.querySelector<HTMLButtonElement>('[aria-label="Copy take-cloud command"]'),
|
||||
).toBeInstanceOf(HTMLButtonElement);
|
||||
|
||||
notice
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Dismiss workspace conflict notice"]')!
|
||||
.click();
|
||||
expect(onDismissWorkspaceConflict).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides the notice after the cleared projection drops the conflict", () => {
|
||||
const container = document.createElement("div");
|
||||
render(renderChat(createChatProps({ workspaceConflict: conflict })), container);
|
||||
expect(container.querySelector(".chat-workspace-conflict-notice")).not.toBeNull();
|
||||
|
||||
render(renderChat(createChatProps()), container);
|
||||
expect(container.querySelector(".chat-workspace-conflict-notice")).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["\u001b[201~echo injected\n", "\r", "\u007f", "\u0085"])(
|
||||
"keeps terminal-control paths visible without building copyable commands (%j)",
|
||||
(controlSequence) => {
|
||||
const entryPath = `src/${controlSequence}unsafe.ts`;
|
||||
const normalizedConflict = workspaceResultConflictFromTranscript({
|
||||
role: "custom",
|
||||
customType: "cloud-workspace-conflict",
|
||||
details: {
|
||||
paths: [entryPath],
|
||||
stagedResultRef: "refs/openclaw/worker-results/claim-unsafe",
|
||||
},
|
||||
});
|
||||
expect(normalizedConflict).toBeDefined();
|
||||
const container = renderChatView({ workspaceConflict: normalizedConflict });
|
||||
expect(container.querySelector(".chat-workspace-conflict-paths code")?.textContent).toBe(
|
||||
workspaceConflictPathForDisplay(entryPath),
|
||||
);
|
||||
expect(container.querySelector(".chat-workspace-conflict-commands")).toBeNull();
|
||||
expect(container.textContent).toContain("will not build a copyable shell command");
|
||||
},
|
||||
);
|
||||
|
||||
it("builds recovery commands for the first shell-safe conflicted path", () => {
|
||||
const normalizedConflict = workspaceResultConflictFromTranscript({
|
||||
role: "custom",
|
||||
customType: "cloud-workspace-conflict",
|
||||
details: {
|
||||
paths: ["src/unsafe\nname.ts", "src/safe.ts"],
|
||||
stagedResultRef: "refs/openclaw/worker-results/claim-mixed",
|
||||
},
|
||||
});
|
||||
const container = renderChatView({ workspaceConflict: normalizedConflict });
|
||||
const commands = [...container.querySelectorAll(".chat-workspace-conflict-commands code")].map(
|
||||
(element) => element.textContent,
|
||||
);
|
||||
expect(commands).toEqual([
|
||||
"git show 'refs/openclaw/worker-results/claim-mixed:src/safe.ts'",
|
||||
"git checkout 'refs/openclaw/worker-results/claim-mixed' -- ':(top,literal)src/safe.ts'",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat conversation width", () => {
|
||||
it("applies a configured width once to the centered transcript frame", () => {
|
||||
const container = renderChatView({
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
resetChatThreadPresentationState,
|
||||
toggleChatThreadSearch,
|
||||
} from "./components/chat-thread.ts";
|
||||
import { renderWorkspaceConflictNotice } from "./components/chat-workspace-conflict.ts";
|
||||
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./input-history.ts";
|
||||
import type { RealtimeTalkConversationEntry } from "./realtime-talk-conversation.ts";
|
||||
import type { RealtimeTalkCameraDevice } from "./realtime-talk-input.ts";
|
||||
@@ -69,6 +70,7 @@ import type { RealtimeTalkLevelSignal } from "./realtime-talk-level.ts";
|
||||
import type { RealtimeTalkStatus } from "./realtime-talk.ts";
|
||||
import type { ChatRunUiStatus } from "./run-lifecycle.ts";
|
||||
import type { CompactionStatus, FallbackStatus, PlanStatus } from "./tool-stream.ts";
|
||||
import type { WorkspaceResultConflict } from "./workspace-conflict.ts";
|
||||
import "../../components/resizable-divider.ts";
|
||||
|
||||
export type ChatProps = {
|
||||
@@ -131,6 +133,8 @@ export type ChatProps = {
|
||||
approvalErrors?: ReadonlyMap<string, string>;
|
||||
approvalNowMs?: number;
|
||||
onApprovalDecision?: (approvalId: string, decision: ExecApprovalDecision) => void | Promise<void>;
|
||||
workspaceConflict?: WorkspaceResultConflict;
|
||||
onDismissWorkspaceConflict?: () => void;
|
||||
sessions: SessionsListResult | null;
|
||||
/** Host context resolving global-alias session keys (scope=global fleets). */
|
||||
sessionHost?: UiSessionDefaultsHost | null;
|
||||
@@ -556,6 +560,10 @@ export function renderChat(props: ChatProps) {
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${renderWorkspaceConflictNotice({
|
||||
conflict: props.workspaceConflict,
|
||||
onDismiss: props.onDismissWorkspaceConflict,
|
||||
})}
|
||||
${props.focusMode && props.onToggleFocusMode
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.actions.exitFocusMode")}>
|
||||
|
||||
@@ -224,6 +224,66 @@ function createMessageGroup(message: unknown, role: string): MessageGroup {
|
||||
};
|
||||
}
|
||||
|
||||
describe("cloud workspace conflict transcript messages", () => {
|
||||
it("renders the custom event as a bounded structured status card", () => {
|
||||
const container = document.createElement("div");
|
||||
renderGroupedMessage(
|
||||
container,
|
||||
{
|
||||
role: "custom",
|
||||
customType: "cloud-workspace-conflict",
|
||||
content: "fallback summary that should not render as plain text",
|
||||
details: {
|
||||
paths: [
|
||||
"src/one.ts",
|
||||
"src/two.ts",
|
||||
"src/three.ts",
|
||||
"src/four.ts",
|
||||
"src/five.ts",
|
||||
"src/six.ts",
|
||||
],
|
||||
stagedResultRef: "refs/openclaw/worker-results/claim-456",
|
||||
totalCount: 7,
|
||||
},
|
||||
timestamp: 1,
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(container.querySelector(".chat-group.workspace-conflict")).not.toBeNull();
|
||||
const card = expectElement(container, ".chat-workspace-conflict-event", HTMLDivElement);
|
||||
expect(card.textContent).toContain("Cloud result applied with 7 conflicts");
|
||||
expect(card.querySelectorAll(".chat-workspace-conflict-paths li")).toHaveLength(5);
|
||||
expect(card.textContent).toContain("+2 more paths");
|
||||
expect(card.textContent).toContain("refs/openclaw/worker-results/claim-456");
|
||||
expect(card.querySelector(".chat-text")).toBeNull();
|
||||
expect(container.querySelector(".chat-sender-name")?.textContent).toBe("Cloud workspace");
|
||||
});
|
||||
|
||||
it("renders terminal-control filenames as escaped durable history", () => {
|
||||
const container = document.createElement("div");
|
||||
renderGroupedMessage(
|
||||
container,
|
||||
{
|
||||
role: "custom",
|
||||
customType: "cloud-workspace-conflict",
|
||||
content: "fallback summary",
|
||||
details: {
|
||||
paths: ["src/line\nbreak.ts"],
|
||||
stagedResultRef: "refs/openclaw/worker-results/claim-control",
|
||||
},
|
||||
timestamp: 1,
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(container.querySelector(".chat-workspace-conflict-paths code")?.textContent).toBe(
|
||||
"src/line\\u{000a}break.ts",
|
||||
);
|
||||
expect(container.textContent).toContain("refs/openclaw/worker-results/claim-control");
|
||||
});
|
||||
});
|
||||
|
||||
function createAssistantCanvasBlock(params: {
|
||||
suffix: string;
|
||||
title?: string;
|
||||
|
||||
@@ -59,6 +59,13 @@ import { getSafeLocalStorage } from "../../../local-storage.ts";
|
||||
import { renderChatAvatar } from "../chat-avatar.ts";
|
||||
import { persistedMessageEntryId } from "../chat-thread.ts";
|
||||
import type { PlanStatus } from "../tool-stream.ts";
|
||||
import {
|
||||
visibleWorkspaceConflictPaths,
|
||||
workspaceConflictCount,
|
||||
workspaceConflictPathForDisplay,
|
||||
workspaceResultConflictFromTranscript,
|
||||
type WorkspaceResultConflict,
|
||||
} from "../workspace-conflict.ts";
|
||||
import { renderChatAuthorAvatar } from "./chat-author-avatar.ts";
|
||||
import { renderChatPlanChecklist } from "./chat-plan-checklist.ts";
|
||||
import { renderChatQuestionSummary } from "./chat-question-card.ts";
|
||||
@@ -835,6 +842,9 @@ function buildGroupedMessageRenderOptions(
|
||||
|
||||
export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroupOptions) {
|
||||
const normalizedRole = normalizeRoleForGrouping(group.role);
|
||||
const isWorkspaceConflict = group.messages.every((item) =>
|
||||
Boolean(workspaceResultConflictFromTranscript(item.message)),
|
||||
);
|
||||
const assistantName = opts.assistantName ?? "Assistant";
|
||||
const resolvedUserName = resolveLocalUserName({
|
||||
name: opts.userName ?? null,
|
||||
@@ -851,7 +861,9 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
? (userLabel ?? assistantName)
|
||||
: normalizedRole === "tool"
|
||||
? "Tool"
|
||||
: normalizedRole;
|
||||
: isWorkspaceConflict
|
||||
? t("chat.workspaceConflict.eventSender")
|
||||
: normalizedRole;
|
||||
const roleClass =
|
||||
normalizedRole === "user"
|
||||
? "user"
|
||||
@@ -859,7 +871,9 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
? "assistant"
|
||||
: normalizedRole === "tool"
|
||||
? "tool"
|
||||
: "other";
|
||||
: isWorkspaceConflict
|
||||
? "workspace-conflict"
|
||||
: "other";
|
||||
|
||||
// Aggregate usage/cost/model across all messages in the group
|
||||
const meta = extractGroupMeta(group, opts.contextWindow ?? null);
|
||||
@@ -2344,6 +2358,10 @@ function renderGroupedMessage(
|
||||
const sourceRole = normalizeRoleForGrouping(role);
|
||||
const normalizedMessage = normalizeMessage(message);
|
||||
const normalizedRole = normalizeRoleForGrouping(normalizedMessage.role);
|
||||
const workspaceConflict = workspaceResultConflictFromTranscript(message);
|
||||
if (workspaceConflict) {
|
||||
return renderWorkspaceConflictTranscriptMessage(workspaceConflict, messageKey, opts.entryId);
|
||||
}
|
||||
const isToolShell = normalizedRole === "tool";
|
||||
const isStandaloneToolMessage = isStandaloneToolMessageForDisplay(message);
|
||||
|
||||
@@ -2668,6 +2686,52 @@ function renderGroupedMessage(
|
||||
`;
|
||||
}
|
||||
|
||||
function renderWorkspaceConflictTranscriptMessage(
|
||||
conflict: WorkspaceResultConflict,
|
||||
messageKey: string,
|
||||
entryId?: string,
|
||||
) {
|
||||
const count = workspaceConflictCount(conflict);
|
||||
const visible = visibleWorkspaceConflictPaths(conflict);
|
||||
return html`
|
||||
<div
|
||||
class="chat-bubble chat-bubble--workspace-conflict"
|
||||
data-message-id=${messageKey}
|
||||
data-entry-id=${entryId || nothing}
|
||||
>
|
||||
<div class="chat-workspace-conflict-event" role="status">
|
||||
<div class="chat-workspace-conflict-event__header">
|
||||
<span aria-hidden="true">${icons.alertTriangle}</span>
|
||||
<strong
|
||||
>${t(
|
||||
count === 1
|
||||
? "chat.workspaceConflict.eventTitleOne"
|
||||
: "chat.workspaceConflict.eventTitleMany",
|
||||
{ count: String(count) },
|
||||
)}</strong
|
||||
>
|
||||
</div>
|
||||
<p>${t("chat.workspaceConflict.eventDescription")}</p>
|
||||
<ul class="chat-workspace-conflict-paths">
|
||||
${visible.paths.map(
|
||||
(entryPath) =>
|
||||
html`<li><code>${workspaceConflictPathForDisplay(entryPath)}</code></li>`,
|
||||
)}
|
||||
</ul>
|
||||
${visible.remaining > 0
|
||||
? html`<div class="chat-workspace-conflict-more">
|
||||
${t("chat.workspaceConflict.morePaths", { count: String(visible.remaining) })}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="chat-workspace-conflict-ref">
|
||||
<span>${t("chat.workspaceConflict.stagedResult")}</span>
|
||||
<code>${conflict.stagedResultRef}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMarkdownText(
|
||||
markdown: string,
|
||||
isStreaming: boolean,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { renderCopyButton } from "../../../components/copy-button.ts";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import {
|
||||
visibleWorkspaceConflictPaths,
|
||||
workspaceConflictCount,
|
||||
workspaceConflictGitCommands,
|
||||
workspaceConflictPathForDisplay,
|
||||
type WorkspaceResultConflict,
|
||||
} from "../workspace-conflict.ts";
|
||||
|
||||
export function renderWorkspaceConflictNotice(props: {
|
||||
conflict?: WorkspaceResultConflict;
|
||||
onDismiss?: () => void;
|
||||
}) {
|
||||
const conflict = props.conflict;
|
||||
if (!conflict) {
|
||||
return nothing;
|
||||
}
|
||||
const count = workspaceConflictCount(conflict);
|
||||
const visible = visibleWorkspaceConflictPaths(conflict);
|
||||
const commands = workspaceConflictGitCommands(conflict);
|
||||
const title = t(
|
||||
count === 1 ? "chat.workspaceConflict.titleOne" : "chat.workspaceConflict.titleMany",
|
||||
{ count: String(count) },
|
||||
);
|
||||
return html`
|
||||
<div class="callout warn callout--dismissible chat-workspace-conflict-notice" role="status">
|
||||
<div class="callout__content chat-workspace-conflict-notice__content">
|
||||
<div class="chat-workspace-conflict-notice__title">
|
||||
<span aria-hidden="true">${icons.alertTriangle}</span>
|
||||
<strong>${title}</strong>
|
||||
</div>
|
||||
<p>${t("chat.workspaceConflict.description")}</p>
|
||||
<ul class="chat-workspace-conflict-paths">
|
||||
${visible.paths.map(
|
||||
(entryPath) =>
|
||||
html`<li><code>${workspaceConflictPathForDisplay(entryPath)}</code></li>`,
|
||||
)}
|
||||
</ul>
|
||||
${visible.remaining > 0
|
||||
? html`<div class="chat-workspace-conflict-more">
|
||||
${t("chat.workspaceConflict.morePaths", { count: String(visible.remaining) })}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="chat-workspace-conflict-ref">
|
||||
<span>${t("chat.workspaceConflict.stagedResult")}</span>
|
||||
<code>${conflict.stagedResultRef}</code>
|
||||
${renderCopyButton(
|
||||
conflict.stagedResultRef,
|
||||
t("chat.workspaceConflict.copyStagedResult"),
|
||||
)}
|
||||
</div>
|
||||
${commands
|
||||
? html`<div class="chat-workspace-conflict-commands">
|
||||
<div>
|
||||
<span>${t("chat.workspaceConflict.inspectCloud")}</span>
|
||||
<code>${commands.inspect}</code>
|
||||
${renderCopyButton(
|
||||
commands.inspect,
|
||||
t("chat.workspaceConflict.copyInspectCommand"),
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span>${t("chat.workspaceConflict.takeCloud")}</span>
|
||||
<code>${commands.takeCloud}</code>
|
||||
${renderCopyButton(
|
||||
commands.takeCloud,
|
||||
t("chat.workspaceConflict.copyTakeCommand"),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p class="chat-workspace-conflict-command-help">
|
||||
${t("chat.workspaceConflict.commandHelp")}
|
||||
</p>`
|
||||
: html`<p class="chat-workspace-conflict-command-help">
|
||||
${t("chat.workspaceConflict.commandsUnavailable")}
|
||||
</p>`}
|
||||
</div>
|
||||
${props.onDismiss
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.workspaceConflict.dismiss")}>
|
||||
<button
|
||||
class="callout__dismiss"
|
||||
type="button"
|
||||
@click=${props.onDismiss}
|
||||
aria-label=${t("chat.workspaceConflict.dismiss")}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
|
||||
const CLOUD_WORKSPACE_CONFLICT_TRANSCRIPT_TYPE = "cloud-workspace-conflict";
|
||||
const WORKSPACE_CONFLICT_VISIBLE_PATH_LIMIT = 5;
|
||||
|
||||
export type WorkspaceResultConflict = {
|
||||
paths: string[];
|
||||
stagedResultRef: string;
|
||||
totalCount?: number;
|
||||
};
|
||||
|
||||
function hasTerminalControl(entryPath: string): boolean {
|
||||
// Copied commands must not preserve terminal controls: bracketed-paste terminators
|
||||
// can turn a displayed filename into executed shell input.
|
||||
return Array.from(entryPath).some((character) => {
|
||||
const codePoint = character.codePointAt(0);
|
||||
return (
|
||||
codePoint !== undefined && (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isWorkspaceConflictPath(entryPath: string): boolean {
|
||||
if (!entryPath || entryPath.startsWith("/") || entryPath.includes("\0")) {
|
||||
return false;
|
||||
}
|
||||
return entryPath
|
||||
.split("/")
|
||||
.every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
||||
}
|
||||
|
||||
export function workspaceConflictPathForDisplay(entryPath: string): string {
|
||||
return Array.from(entryPath)
|
||||
.map((character) => {
|
||||
if (character === "\\") {
|
||||
return "\\\\";
|
||||
}
|
||||
const codePoint = character.codePointAt(0);
|
||||
return codePoint !== undefined &&
|
||||
(codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f))
|
||||
? `\\u{${codePoint.toString(16).padStart(4, "0")}}`
|
||||
: character;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function normalizeWorkspaceResultConflict(value: unknown): WorkspaceResultConflict | undefined {
|
||||
const record = asNullableRecord(value);
|
||||
if (!record || !Array.isArray(record.paths) || record.paths.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const paths = record.paths.filter(
|
||||
(entryPath): entryPath is string =>
|
||||
typeof entryPath === "string" && isWorkspaceConflictPath(entryPath),
|
||||
);
|
||||
if (
|
||||
paths.length !== record.paths.length ||
|
||||
typeof record.stagedResultRef !== "string" ||
|
||||
!/^refs\/openclaw\/worker-results\/[A-Za-z0-9-]+$/u.test(record.stagedResultRef) ||
|
||||
(record.totalCount !== undefined &&
|
||||
(!Number.isSafeInteger(record.totalCount) || (record.totalCount as number) < paths.length))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
paths,
|
||||
stagedResultRef: record.stagedResultRef,
|
||||
...(record.totalCount === undefined ? {} : { totalCount: record.totalCount as number }),
|
||||
};
|
||||
}
|
||||
|
||||
export function workspaceResultConflictFromPlacement(
|
||||
placement: GatewaySessionRow["placement"],
|
||||
): WorkspaceResultConflict | undefined {
|
||||
if (!placement || !("workspaceResultConflict" in placement)) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeWorkspaceResultConflict(placement.workspaceResultConflict);
|
||||
}
|
||||
|
||||
export function workspaceResultConflictFromTranscript(
|
||||
message: unknown,
|
||||
): WorkspaceResultConflict | undefined {
|
||||
const record = asNullableRecord(message);
|
||||
if (record?.role !== "custom" || record.customType !== CLOUD_WORKSPACE_CONFLICT_TRANSCRIPT_TYPE) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeWorkspaceResultConflict(record.details);
|
||||
}
|
||||
|
||||
export function workspaceConflictCount(conflict: WorkspaceResultConflict): number {
|
||||
return Math.max(conflict.paths.length, conflict.totalCount ?? conflict.paths.length);
|
||||
}
|
||||
|
||||
export function visibleWorkspaceConflictPaths(conflict: WorkspaceResultConflict): {
|
||||
paths: string[];
|
||||
remaining: number;
|
||||
} {
|
||||
const paths = conflict.paths.slice(0, WORKSPACE_CONFLICT_VISIBLE_PATH_LIMIT);
|
||||
return {
|
||||
paths,
|
||||
remaining: Math.max(0, workspaceConflictCount(conflict) - paths.length),
|
||||
};
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export function workspaceConflictGitCommands(conflict: WorkspaceResultConflict):
|
||||
| {
|
||||
inspect: string;
|
||||
takeCloud: string;
|
||||
}
|
||||
| undefined {
|
||||
const entryPath = conflict.paths.find((candidate) => !hasTerminalControl(candidate));
|
||||
if (!entryPath) {
|
||||
return undefined;
|
||||
}
|
||||
const stagedPath = shellQuote(`${conflict.stagedResultRef}:${entryPath}`);
|
||||
const stagedRef = shellQuote(conflict.stagedResultRef);
|
||||
const literalPathspec = shellQuote(`:(top,literal)${entryPath}`);
|
||||
return {
|
||||
inspect: `git show ${stagedPath}`,
|
||||
takeCloud: `git checkout ${stagedRef} -- ${literalPathspec}`,
|
||||
};
|
||||
}
|
||||
@@ -388,6 +388,30 @@ img.chat-avatar {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.chat-group.workspace-conflict .chat-group-messages {
|
||||
max-width: min(760px, calc(100% - 46px));
|
||||
}
|
||||
|
||||
.chat-group.workspace-conflict .chat-avatar {
|
||||
background: color-mix(in srgb, var(--warn) 14%, var(--secondary));
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.chat-bubble--workspace-conflict {
|
||||
width: 100%;
|
||||
border-color: color-mix(in srgb, var(--warn) 34%, var(--border));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--warn) 10%, var(--card)) 0%,
|
||||
color-mix(in srgb, var(--warn) 5%, var(--card)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.chat-workspace-conflict-event {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Tool messages render flat rows, not bubbles: the shell only constrains
|
||||
width; all bubble chrome (border/background/padding) is reset. */
|
||||
.chat-bubble--tool-shell {
|
||||
|
||||
@@ -144,6 +144,102 @@ openclaw-chat-page {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.chat-workspace-conflict-notice {
|
||||
flex: 0 0 auto;
|
||||
margin: 12px 16px 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-workspace-conflict-notice__content {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-workspace-conflict-notice__content p,
|
||||
.chat-workspace-conflict-event p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chat-workspace-conflict-notice__title,
|
||||
.chat-workspace-conflict-event__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: color-mix(in srgb, var(--warn) 82%, var(--text-strong));
|
||||
}
|
||||
|
||||
.chat-workspace-conflict-notice__title svg,
|
||||
.chat-workspace-conflict-event__header svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chat-workspace-conflict-paths {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.chat-workspace-conflict-paths code,
|
||||
.chat-workspace-conflict-ref code,
|
||||
.chat-workspace-conflict-commands code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chat-workspace-conflict-more {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-workspace-conflict-command-help {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-workspace-conflict-ref,
|
||||
.chat-workspace-conflict-commands > div {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-workspace-conflict-ref > span,
|
||||
.chat-workspace-conflict-commands span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-workspace-conflict-ref code,
|
||||
.chat-workspace-conflict-commands code {
|
||||
min-width: 0;
|
||||
padding: 4px 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--bg) 72%, transparent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chat-workspace-conflict-commands {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.chat-workspace-conflict-ref,
|
||||
.chat-workspace-conflict-commands > div {
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
}
|
||||
|
||||
.chat-workspace-conflict-ref > span,
|
||||
.chat-workspace-conflict-commands span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat::after,
|
||||
.chat > * {
|
||||
|
||||
@@ -5249,6 +5249,10 @@ td.data-table-key-col {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.session-row-badge--cloud[data-workspace-conflicts] {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.session-row-badge svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
|
||||
@@ -156,6 +156,73 @@ describe("AppSidebar agent chip", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates loaded child workspace conflicts to a collapsed parent", async () => {
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const harness = createSessionsHarness("main", ["agent:main:parent"]);
|
||||
harness.list.mockResolvedValue({
|
||||
ts: 2,
|
||||
path: "",
|
||||
count: 1,
|
||||
defaults: { modelProvider: null, model: null, contextTokens: null },
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:worker:child",
|
||||
spawnedBy: "agent:main:parent",
|
||||
kind: "direct",
|
||||
label: "Conflicted child",
|
||||
updatedAt: 2,
|
||||
placement: {
|
||||
state: "reclaimed",
|
||||
generation: 1,
|
||||
createdAtMs: 1,
|
||||
updatedAtMs: 2,
|
||||
stateChangedAtMs: 2,
|
||||
workspaceResultConflict: {
|
||||
paths: ["src/local.ts", "src/other.ts"],
|
||||
stagedResultRef: "refs/openclaw/worker-results/claim-child",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const { sidebar } = await mountSidebar(gateway, harness.sessions);
|
||||
harness.publishList({
|
||||
result: {
|
||||
ts: 2,
|
||||
path: "",
|
||||
count: 1,
|
||||
defaults: { modelProvider: null, model: null, contextTokens: null },
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:main:parent",
|
||||
kind: "direct",
|
||||
label: "Parent task",
|
||||
updatedAt: 1,
|
||||
childSessions: ["agent:worker:child"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const toggle = sidebar.querySelector<HTMLButtonElement>("[data-child-session-toggle]");
|
||||
toggle?.click();
|
||||
await waitForFast(() => expect(harness.list).toHaveBeenCalledOnce());
|
||||
await waitForFast(() =>
|
||||
expect(sidebar.querySelector('[data-session-key="agent:worker:child"]')).not.toBeNull(),
|
||||
);
|
||||
|
||||
toggle?.click();
|
||||
await sidebar.updateComplete;
|
||||
expect(sidebar.querySelector('[data-session-key="agent:worker:child"]')).toBeNull();
|
||||
const parentBadge = sidebar.querySelector<HTMLElement>(
|
||||
'[data-session-key="agent:main:parent"] .session-row-badge--cloud',
|
||||
);
|
||||
expect(parentBadge?.dataset.workspaceConflicts).toBe("2");
|
||||
expect(parentBadge?.dataset.placementState).toBeUndefined();
|
||||
expect(parentBadge?.getAttribute("title")).toBe("Cloud worker children: 2 workspace conflicts");
|
||||
});
|
||||
|
||||
it("loads every child-session page before marking a parent complete", async () => {
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const harness = createSessionsHarness("main", ["agent:main:parent"]);
|
||||
|
||||
Reference in New Issue
Block a user