mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
fix(ui): restore approved inbox panel layout after lazy-chunk split (#128201)
* fix(ui): restore Inbox panel spacing * fix(ui): stabilize Inbox update state * fix(ui): keep updates in Inbox * fix(ui): keep update status authoritative * test(ui): add gateway-shaped update fixtures * fix(ui): preserve read-only update campaign visibility * fix(ui): preserve update handoff after Inbox closes * fix(ui): keep floating updates Inbox-owned * refactor(ui): isolate update schedule projection
This commit is contained in:
committed by
GitHub
parent
bbdf7abcff
commit
5000f32b8d
+186
-66
@@ -17,7 +17,9 @@ import { applySharedChannelFieldHelp } from "../src/config/schema.channel-field-
|
||||
import { buildBaseHints } from "../src/config/schema.hints.js";
|
||||
import { applyConfigTierHints, applyResolvedConfigTierHints } from "../src/config/schema.tiers.js";
|
||||
import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "../src/gateway/control-ui-contract.js";
|
||||
import type { UpdateScheduleState } from "../ui/src/api/types.ts";
|
||||
import { buildUpdateRestartSentinelPayload } from "../src/infra/update-restart-sentinel-payload.js";
|
||||
import type { UpdateRunResult } from "../src/infra/update-runner.js";
|
||||
import type { UpdateAvailable, UpdateScheduleState } from "../ui/src/api/types.ts";
|
||||
import {
|
||||
createControlUiMockBootstrapConfig,
|
||||
createControlUiMockGatewayInitScript,
|
||||
@@ -40,7 +42,15 @@ import { buildSkillWorkshopMocks } from "./control-ui-mock-skill-workshop.js";
|
||||
|
||||
type CliOptions = {
|
||||
allowedHosts: string[];
|
||||
fixture?: "approval" | "board" | "code-fences" | "swarm" | "workboard";
|
||||
fixture?:
|
||||
| "approval"
|
||||
| "board"
|
||||
| "code-fences"
|
||||
| "swarm"
|
||||
| "update-available"
|
||||
| "update-blocked"
|
||||
| "update-failed"
|
||||
| "workboard";
|
||||
host: string;
|
||||
operatorScopes?: string[];
|
||||
port: number;
|
||||
@@ -81,6 +91,171 @@ const PLAN_DEMO_RUN_ID = "mock-plan-run";
|
||||
const CUSTODIAN_CHAT_REPLY_DELAY_MS = 600;
|
||||
const CHAT_SEND_REPLY_DELAY_MS = 200;
|
||||
|
||||
type UpdateFixture = {
|
||||
available: UpdateAvailable;
|
||||
runResponse: unknown;
|
||||
schedule: UpdateScheduleState;
|
||||
statusResponse: unknown;
|
||||
};
|
||||
|
||||
function buildUpdateFixture(fixture: CliOptions["fixture"], nowMs: number): UpdateFixture | null {
|
||||
if (
|
||||
fixture !== "update-available" &&
|
||||
fixture !== "update-blocked" &&
|
||||
fixture !== "update-failed"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fixture === "update-available") {
|
||||
const available: UpdateAvailable = {
|
||||
currentVersion: "2026.8.1",
|
||||
latestVersion: "2026.8.2",
|
||||
channel: "latest",
|
||||
};
|
||||
const schedule: UpdateScheduleState = {
|
||||
channel: "stable",
|
||||
autoEnabled: false,
|
||||
install: { kind: "package" },
|
||||
target: { kind: "package", version: available.latestVersion },
|
||||
};
|
||||
return {
|
||||
available,
|
||||
schedule,
|
||||
statusResponse: {
|
||||
sentinel: null,
|
||||
updateAvailable: available,
|
||||
effectiveChannel: "stable",
|
||||
schedule,
|
||||
},
|
||||
runResponse: {
|
||||
ok: true,
|
||||
result: {
|
||||
status: "ok",
|
||||
mode: "global",
|
||||
before: { version: available.currentVersion },
|
||||
after: { version: available.latestVersion },
|
||||
steps: [],
|
||||
durationMs: 12_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const currentSha = "83b321ba7d31c04cc6f7a38c87932ec0172b5461";
|
||||
const upstreamSha = "ea2ab707d3ab6f9351dcdb2f3c05054097fbfa62";
|
||||
const available: UpdateAvailable = {
|
||||
currentVersion: "2026.8.1",
|
||||
latestVersion: "2026.8.1",
|
||||
channel: "dev",
|
||||
currentSha,
|
||||
upstreamRef: "origin/main",
|
||||
upstreamSha,
|
||||
commitsBehind: 2,
|
||||
commits: [
|
||||
{ sha: "f6c71c4", subject: "Keep update status authoritative" },
|
||||
{ sha: "ea2ab70", subject: "Move update actions into Inbox" },
|
||||
],
|
||||
};
|
||||
const baseSchedule: UpdateScheduleState = {
|
||||
channel: "dev",
|
||||
autoEnabled: true,
|
||||
install: {
|
||||
kind: "git",
|
||||
git: {
|
||||
status: "behind",
|
||||
currentSha,
|
||||
commitsBehind: 2,
|
||||
commitAtMs: nowMs - 2 * 86_400_000,
|
||||
installedAtMs: nowMs - 7 * 86_400_000,
|
||||
},
|
||||
},
|
||||
target: {
|
||||
kind: "git",
|
||||
upstreamRef: available.upstreamRef ?? "origin/main",
|
||||
upstreamSha,
|
||||
commitsBehind: 2,
|
||||
},
|
||||
};
|
||||
|
||||
if (fixture === "update-blocked") {
|
||||
const schedule: UpdateScheduleState = {
|
||||
...baseSchedule,
|
||||
campaign: {
|
||||
id: "mock-update-waiting-for-idle",
|
||||
state: "waiting-for-idle",
|
||||
announcedAtMs: nowMs - 2 * 60_000,
|
||||
forceAtMs: nowMs + 13 * 60_000,
|
||||
updatedAtMs: nowMs,
|
||||
},
|
||||
};
|
||||
return {
|
||||
available,
|
||||
schedule,
|
||||
statusResponse: {
|
||||
sentinel: null,
|
||||
updateAvailable: available,
|
||||
effectiveChannel: "dev",
|
||||
schedule,
|
||||
},
|
||||
runResponse: {
|
||||
ok: true,
|
||||
result: {
|
||||
status: "skipped",
|
||||
mode: "git",
|
||||
reason: "managed-service-handoff-started",
|
||||
before: { version: available.currentVersion, sha: currentSha },
|
||||
steps: [],
|
||||
durationMs: 0,
|
||||
},
|
||||
handoff: { status: "started" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const schedule: UpdateScheduleState = {
|
||||
...baseSchedule,
|
||||
campaign: {
|
||||
id: "mock-update-before-failure",
|
||||
state: "waiting-for-idle",
|
||||
announcedAtMs: nowMs - 2 * 60_000,
|
||||
forceAtMs: nowMs + 13 * 60_000,
|
||||
updatedAtMs: nowMs,
|
||||
},
|
||||
};
|
||||
const result: UpdateRunResult = {
|
||||
status: "error",
|
||||
mode: "git",
|
||||
root: "/mock/openclaw",
|
||||
reason: "build-failed",
|
||||
before: { version: available.currentVersion, sha: currentSha },
|
||||
after: { version: available.latestVersion, sha: upstreamSha },
|
||||
steps: [
|
||||
{
|
||||
name: "build",
|
||||
command: "pnpm build",
|
||||
cwd: "/mock/openclaw",
|
||||
durationMs: 8_420,
|
||||
stdoutTail: "",
|
||||
stderrTail: "tsc: error TS2345",
|
||||
exitCode: 1,
|
||||
},
|
||||
],
|
||||
durationMs: 11_640,
|
||||
};
|
||||
return {
|
||||
available,
|
||||
schedule,
|
||||
runResponse: { ok: false, result },
|
||||
statusResponse: {
|
||||
sentinel: buildUpdateRestartSentinelPayload({ result, meta: {}, nowMs }),
|
||||
updateAvailable: available,
|
||||
effectiveChannel: "dev",
|
||||
schedule: baseSchedule,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const uiRoot = path.join(repoRoot, "ui");
|
||||
const boardFixturePath = "/__fixtures/board/";
|
||||
@@ -175,6 +350,9 @@ function parseFixture(value: string | undefined): CliOptions["fixture"] {
|
||||
value !== "board" &&
|
||||
value !== "code-fences" &&
|
||||
value !== "swarm" &&
|
||||
value !== "update-available" &&
|
||||
value !== "update-blocked" &&
|
||||
value !== "update-failed" &&
|
||||
value !== "workboard"
|
||||
) {
|
||||
throw new Error(`Unknown Control UI mock fixture: ${value}`);
|
||||
@@ -1645,30 +1823,8 @@ async function createChatPickerScenario(
|
||||
const richAttention = fixture === "approval";
|
||||
const cronMocks = buildCronMocks(Date.now(), { richAttention });
|
||||
const updateFixtureNow = Date.now();
|
||||
const updateSchedule: UpdateScheduleState | null = richAttention
|
||||
? {
|
||||
channel: "dev",
|
||||
autoEnabled: true,
|
||||
install: {
|
||||
kind: "git",
|
||||
git: { status: "behind", commitsBehind: 2 },
|
||||
},
|
||||
target: {
|
||||
kind: "git",
|
||||
upstreamRef: "origin/main",
|
||||
upstreamSha: "a".repeat(40),
|
||||
commitsBehind: 2,
|
||||
},
|
||||
campaign: {
|
||||
id: "mock-update-campaign",
|
||||
state: "waiting-for-idle",
|
||||
announcedAtMs: updateFixtureNow - 2 * 60_000,
|
||||
applyAtMs: updateFixtureNow + 15 * 60_000,
|
||||
forceAtMs: updateFixtureNow + 60 * 60_000,
|
||||
updatedAtMs: updateFixtureNow,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
const updateFixture = buildUpdateFixture(fixture, updateFixtureNow);
|
||||
const updateSchedule = updateFixture?.schedule ?? null;
|
||||
const heldUpdateSchedule: UpdateScheduleState | null = updateSchedule?.campaign
|
||||
? {
|
||||
...updateSchedule,
|
||||
@@ -1793,20 +1949,7 @@ async function createChatPickerScenario(
|
||||
defaultAgentId: "main",
|
||||
serverBuildId: "mock",
|
||||
updateSchedule,
|
||||
updateAvailable:
|
||||
fixture === "approval"
|
||||
? {
|
||||
channel: "dev",
|
||||
currentVersion: "2026.8.1",
|
||||
latestVersion: "2026.8.1",
|
||||
upstreamSha: "a".repeat(40),
|
||||
commitsBehind: 2,
|
||||
commits: [
|
||||
{ sha: "abcdef1234567", subject: "Unify sidebar notifications" },
|
||||
{ sha: "fedcba7654321", subject: "Keep the footer identity compact" },
|
||||
],
|
||||
}
|
||||
: null,
|
||||
updateAvailable: updateFixture?.available ?? null,
|
||||
// Advertised Gateway methods gate session actions (see
|
||||
// ui/src/lib/session-method-access.ts). Omitting the mutation methods left
|
||||
// every session context-menu row disabled, so the harness could not show
|
||||
@@ -1839,7 +1982,7 @@ async function createChatPickerScenario(
|
||||
"sessions.create",
|
||||
"system.info",
|
||||
"terminal.open",
|
||||
...(richAttention ? ["update.hold", "update.run", "update.status"] : []),
|
||||
...(updateFixture ? ["update.hold", "update.run", "update.status"] : []),
|
||||
...(fixture === "workboard"
|
||||
? [
|
||||
"board.get",
|
||||
@@ -2358,31 +2501,8 @@ async function createChatPickerScenario(
|
||||
"update.hold": heldUpdateSchedule
|
||||
? { ok: true, schedule: heldUpdateSchedule }
|
||||
: { ok: false },
|
||||
"update.run": updateSchedule
|
||||
? { ok: false, result: { status: "error", reason: "build-dirty" } }
|
||||
: {},
|
||||
"update.status": updateSchedule
|
||||
? {
|
||||
sentinel: {
|
||||
kind: "update",
|
||||
status: "error",
|
||||
ts: updateFixtureNow,
|
||||
stats: {
|
||||
reason: "build-dirty",
|
||||
steps: [
|
||||
{
|
||||
name: "build",
|
||||
log: {
|
||||
exitCode: 1,
|
||||
stderrTail:
|
||||
"generated artifacts differ from the selected revision after the build completed; preserve the checkout and retry only after reconciling the generated files and verifying the target revision",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
: {},
|
||||
"update.run": updateFixture?.runResponse ?? {},
|
||||
"update.status": updateFixture?.statusResponse ?? {},
|
||||
"usage.status": modelProviders.usageStatus,
|
||||
"device.pair.list": {
|
||||
paired: [
|
||||
|
||||
@@ -424,7 +424,7 @@ describe("OpenClaw native shell", () => {
|
||||
});
|
||||
|
||||
describe("OpenClaw shell update affordance", () => {
|
||||
it("renders floating attention and loud update states only while navigation is collapsed", async () => {
|
||||
it("renders floating attention while keeping update actions in navigation", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const shared = {
|
||||
@@ -448,21 +448,10 @@ describe("OpenClaw shell update affordance", () => {
|
||||
mobileNavLayout: false,
|
||||
});
|
||||
render(renderFloatingUpdateCard({ ...shared, navigationSurfaceHidden: collapsed }), container);
|
||||
const card = container.querySelector<
|
||||
HTMLElement & {
|
||||
canUpdate: boolean;
|
||||
onRefresh: () => void;
|
||||
refreshRequired: boolean;
|
||||
updateComplete: Promise<boolean>;
|
||||
}
|
||||
>("openclaw-sidebar-update-card");
|
||||
expect(card).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector("openclaw-sidebar-attention.sidebar-attention--floating"),
|
||||
).not.toBeNull();
|
||||
await card?.updateComplete;
|
||||
expect(card?.canUpdate).toBe(true);
|
||||
expect(card?.querySelector(".sidebar-update-card")).toBeNull();
|
||||
expect(container.querySelector("openclaw-sidebar-update-card")).toBeNull();
|
||||
|
||||
render(
|
||||
renderFloatingUpdateCard({
|
||||
@@ -473,8 +462,11 @@ describe("OpenClaw shell update affordance", () => {
|
||||
}),
|
||||
container,
|
||||
);
|
||||
expect(card?.refreshRequired).toBe(true);
|
||||
card?.onRefresh();
|
||||
const refreshCard = container.querySelector<
|
||||
HTMLElement & { onRefresh: () => void; refreshRequired: boolean }
|
||||
>("openclaw-sidebar-update-card");
|
||||
expect(refreshCard?.refreshRequired).toBe(true);
|
||||
refreshCard?.onRefresh();
|
||||
expect(shared.onRefresh).toHaveBeenCalledOnce();
|
||||
expect(shared.onUpdate).not.toHaveBeenCalled();
|
||||
|
||||
|
||||
@@ -375,19 +375,7 @@ export function renderApplicationShell(host: ShellViewHost) {
|
||||
lobsterPetSounds: uiSettings.lobsterPetSounds === true,
|
||||
gatewayVersion: config.serverVersion ?? gatewaySnapshot.hello?.server?.version ?? null,
|
||||
devGitBranch: config.devGitBranch,
|
||||
updateAvailable: navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable,
|
||||
updateSchedule: navigationSurfaceHidden ? null : overlaySnapshot.updateSchedule,
|
||||
heldUpdateCampaignId: overlaySnapshot.heldUpdateCampaignId,
|
||||
updateBusy,
|
||||
updateStatusBanner: overlaySnapshot.updateStatusBanner,
|
||||
watchUpdateProgress,
|
||||
canUpdate,
|
||||
canHoldUpdate,
|
||||
onUpdate: () => void context.overlays.runUpdate(),
|
||||
refreshRequired: navigationSurfaceHidden ? false : overlaySnapshot.controlUiRefreshRequired,
|
||||
onRefresh: () => host.refreshControlUi(),
|
||||
onHoldUpdate: () => context.overlays.holdUpdate(),
|
||||
onReviewUpdate: () => host.navigate("updates"),
|
||||
onOpenApprovals: () => host.openApprovals(),
|
||||
onRetryConnect: () => context.gateway.connect(),
|
||||
onOpenNewSession: openNewSession,
|
||||
|
||||
@@ -41,9 +41,7 @@ export function renderFloatingUpdateCard(params: {
|
||||
// it still needs the floating copy while navigation is hidden.
|
||||
const desktopNavigationHidden = params.navigationSurfaceHidden && !params.mobileNavLayout;
|
||||
const showAttention = desktopNavigationHidden && !params.onboarding && !params.compact;
|
||||
const showUpdateCard =
|
||||
!params.compact &&
|
||||
(params.refreshRequired || (!params.onboarding && params.navigationSurfaceHidden));
|
||||
const showUpdateCard = !params.compact && params.refreshRequired;
|
||||
if (!showAttention && !showUpdateCard) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ApplicationOverlaySnapshot = {
|
||||
heldUpdateCampaignId: string | null;
|
||||
updateRunning: boolean;
|
||||
updateStatusRefreshing: boolean;
|
||||
updateCampaignStatusHydrated: boolean;
|
||||
updateReconciliationPending: boolean;
|
||||
updateStatusBanner: ApplicationStatusBanner | null;
|
||||
recordedUpdateAttempt: RecordedUpdateAttempt | null;
|
||||
|
||||
@@ -241,11 +241,59 @@ describe("application update campaign overlays", () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await flushMicrotasks();
|
||||
expect(request.mock.calls.filter(([method]) => method === "update.status")).toHaveLength(1);
|
||||
expect(overlays.snapshot.updateCampaignStatusHydrated).toBe(false);
|
||||
expect(overlays.snapshot.updateSchedule?.campaign?.id).toBe("campaign-2");
|
||||
} finally {
|
||||
overlays.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("holds a campaign surface until its first authoritative status arrives", async () => {
|
||||
vi.useFakeTimers();
|
||||
const updateStatus = deferred();
|
||||
const request = vi.fn<RequestFn>((method) =>
|
||||
method === "update.status" ? updateStatus.promise : Promise.resolve({}),
|
||||
);
|
||||
const harness = createGatewayHarness(client(request));
|
||||
harness.update({
|
||||
hello: {
|
||||
auth: { role: "operator", scopes: ["operator.admin"] },
|
||||
snapshot: {
|
||||
updateSchedule: {
|
||||
channel: "dev",
|
||||
autoEnabled: true,
|
||||
campaign: {
|
||||
id: "campaign-blocked",
|
||||
state: "waiting-for-idle",
|
||||
announcedAtMs: 1_000,
|
||||
forceAtMs: 901_000,
|
||||
updatedAtMs: 1_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ApplicationGatewaySnapshot["hello"],
|
||||
});
|
||||
const overlays = createApplicationOverlays(harness.gateway);
|
||||
|
||||
expect(overlays.snapshot.updateCampaignStatusHydrated).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(request.mock.calls.filter(([method]) => method === "update.status")).toHaveLength(1);
|
||||
expect(overlays.snapshot.updateCampaignStatusHydrated).toBe(false);
|
||||
|
||||
updateStatus.resolve({
|
||||
sentinel: {
|
||||
kind: "update",
|
||||
status: "error",
|
||||
stats: { reason: "build-dirty" },
|
||||
},
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(overlays.snapshot.updateCampaignStatusHydrated).toBe(true);
|
||||
expect(overlays.snapshot.updateStatusBanner?.text).toContain("build-dirty");
|
||||
overlays.dispose();
|
||||
});
|
||||
|
||||
it("holds an active campaign and adopts the returned schedule", async () => {
|
||||
const request = vi.fn<RequestFn>(async (method) =>
|
||||
method === "update.hold"
|
||||
|
||||
+15
-29
@@ -3,7 +3,7 @@ import {
|
||||
type GatewayUpdateAvailableEventPayload,
|
||||
} from "../../../src/gateway/events.js";
|
||||
import type { GatewayEventFrame } from "../api/gateway.ts";
|
||||
import type { UpdateHoldResult, UpdateScheduleState } from "../api/types.ts";
|
||||
import type { UpdateHoldResult } from "../api/types.ts";
|
||||
import { controlUiBuildDiffersFrom } from "../build-info.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import {
|
||||
@@ -53,12 +53,12 @@ import {
|
||||
type UpdateRestartStatusResponse,
|
||||
type UpdateRunResponse,
|
||||
} from "./update-overlay-helpers.ts";
|
||||
import { readUpdateScheduleValue } from "./update-schedule-dto.ts";
|
||||
import {
|
||||
readUpdateAvailable,
|
||||
readUpdateAvailableValue,
|
||||
readUpdateSchedule,
|
||||
readUpdateScheduleValue,
|
||||
} from "./update-schedule-dto.ts";
|
||||
projectConnectedUpdateSnapshot,
|
||||
projectUpdateAvailableEvent,
|
||||
resolveHeldUpdateCampaignId,
|
||||
} from "./update-schedule-projection.ts";
|
||||
import {
|
||||
announceRecordedUpdateSuccess,
|
||||
announceVerifiedUpdateInstall,
|
||||
@@ -82,6 +82,7 @@ export function createApplicationOverlays(
|
||||
heldUpdateCampaignId: null,
|
||||
updateRunning: false,
|
||||
updateStatusRefreshing: false,
|
||||
updateCampaignStatusHydrated: true,
|
||||
updateReconciliationPending: false,
|
||||
updateStatusBanner: null,
|
||||
recordedUpdateAttempt: null,
|
||||
@@ -184,10 +185,6 @@ export function createApplicationOverlays(
|
||||
snapshot = { ...snapshot, recordedUpdateAttempt };
|
||||
publish();
|
||||
};
|
||||
const heldCampaignId = (schedule: UpdateScheduleState | null) =>
|
||||
schedule?.campaign?.holdUntilMs !== undefined
|
||||
? schedule.campaign.id
|
||||
: snapshot.heldUpdateCampaignId;
|
||||
const updateVerification = createUpdateVerificationController({
|
||||
getPending: () => pendingUpdate,
|
||||
clearPending: () => {
|
||||
@@ -214,6 +211,7 @@ export function createApplicationOverlays(
|
||||
recordedUpdateAttempt: snapshot.recordedUpdateAttempt,
|
||||
heldUpdateCampaignId: snapshot.heldUpdateCampaignId,
|
||||
}),
|
||||
updateCampaignStatusHydrated: true,
|
||||
};
|
||||
publish();
|
||||
};
|
||||
@@ -327,6 +325,7 @@ export function createApplicationOverlays(
|
||||
updateSchedule: null,
|
||||
updateRunning: false,
|
||||
updateStatusRefreshing: false,
|
||||
updateCampaignStatusHydrated: true,
|
||||
};
|
||||
updateCampaignPoller.stop();
|
||||
if (next.phase === "reload-required") {
|
||||
@@ -341,8 +340,6 @@ export function createApplicationOverlays(
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
const updateSchedule =
|
||||
connectedSourceChanged || helloChanged ? readUpdateSchedule(next.hello) : undefined;
|
||||
const serverBuildIdentity = {
|
||||
version: next.hello?.server?.version,
|
||||
buildId: next.hello?.server?.buildId,
|
||||
@@ -352,11 +349,7 @@ export function createApplicationOverlays(
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
...(connectedSourceChanged || helloChanged
|
||||
? {
|
||||
updateAvailable: readUpdateAvailable(next.hello),
|
||||
updateSchedule: updateSchedule ?? null,
|
||||
heldUpdateCampaignId: heldCampaignId(updateSchedule ?? null),
|
||||
}
|
||||
? projectConnectedUpdateSnapshot(snapshot, next.hello)
|
||||
: {}),
|
||||
controlUiRefreshRequired: connectedSourceChanged
|
||||
? (exactBuildIdentityAvailable || connectedEpoch > 0) &&
|
||||
@@ -408,19 +401,9 @@ export function createApplicationOverlays(
|
||||
}
|
||||
if (event.event === GATEWAY_EVENT_UPDATE_AVAILABLE) {
|
||||
const payload = event.payload as GatewayUpdateAvailableEventPayload | undefined;
|
||||
const updateSchedule =
|
||||
payload && Object.hasOwn(payload, "schedule")
|
||||
? readUpdateScheduleValue(payload.schedule)
|
||||
: undefined;
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
updateAvailable: readUpdateAvailableValue(payload?.updateAvailable),
|
||||
...(updateSchedule !== undefined
|
||||
? {
|
||||
updateSchedule,
|
||||
heldUpdateCampaignId: heldCampaignId(updateSchedule),
|
||||
}
|
||||
: {}),
|
||||
...projectUpdateAvailableEvent(snapshot, payload),
|
||||
};
|
||||
publish();
|
||||
updateCampaignPoller.sync();
|
||||
@@ -580,7 +563,10 @@ export function createApplicationOverlays(
|
||||
...(updateSchedule !== undefined ? { updateSchedule } : {}),
|
||||
heldUpdateCampaignId: response.ok
|
||||
? campaign.id
|
||||
: heldCampaignId(updateSchedule ?? null),
|
||||
: resolveHeldUpdateCampaignId(
|
||||
updateSchedule ?? snapshot.updateSchedule,
|
||||
snapshot.heldUpdateCampaignId,
|
||||
),
|
||||
};
|
||||
publish();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { GatewayHelloOk } from "../api/gateway.ts";
|
||||
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
|
||||
import {
|
||||
readUpdateAvailable,
|
||||
readUpdateAvailableValue,
|
||||
readUpdateSchedule,
|
||||
readUpdateScheduleValue,
|
||||
} from "./update-schedule-dto.ts";
|
||||
|
||||
type UpdateScheduleProjection = {
|
||||
updateAvailable: UpdateAvailable | null;
|
||||
updateSchedule: UpdateScheduleState | null;
|
||||
heldUpdateCampaignId: string | null;
|
||||
updateCampaignStatusHydrated: boolean;
|
||||
};
|
||||
|
||||
function retainCampaignStatusHydration(
|
||||
current: UpdateScheduleState | null,
|
||||
next: UpdateScheduleState | null | undefined,
|
||||
hydrated: boolean,
|
||||
): boolean {
|
||||
const currentCampaign = current?.campaign;
|
||||
const nextCampaign = next?.campaign;
|
||||
return (
|
||||
!nextCampaign ||
|
||||
(hydrated &&
|
||||
currentCampaign?.id === nextCampaign.id &&
|
||||
currentCampaign.updatedAtMs === nextCampaign.updatedAtMs)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveHeldUpdateCampaignId(
|
||||
schedule: UpdateScheduleState | null,
|
||||
currentCampaignId: string | null,
|
||||
): string | null {
|
||||
return schedule?.campaign?.holdUntilMs !== undefined ? schedule.campaign.id : currentCampaignId;
|
||||
}
|
||||
|
||||
export function projectConnectedUpdateSnapshot(
|
||||
current: UpdateScheduleProjection,
|
||||
hello: GatewayHelloOk | null,
|
||||
): UpdateScheduleProjection {
|
||||
const updateSchedule = readUpdateSchedule(hello);
|
||||
return {
|
||||
updateAvailable: readUpdateAvailable(hello),
|
||||
updateSchedule,
|
||||
heldUpdateCampaignId: resolveHeldUpdateCampaignId(updateSchedule, current.heldUpdateCampaignId),
|
||||
updateCampaignStatusHydrated: retainCampaignStatusHydration(
|
||||
current.updateSchedule,
|
||||
updateSchedule,
|
||||
current.updateCampaignStatusHydrated,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function projectUpdateAvailableEvent(
|
||||
current: UpdateScheduleProjection,
|
||||
payload: { updateAvailable?: unknown; schedule?: unknown } | undefined,
|
||||
): Partial<UpdateScheduleProjection> {
|
||||
const updateSchedule =
|
||||
payload && Object.hasOwn(payload, "schedule")
|
||||
? readUpdateScheduleValue(payload.schedule)
|
||||
: undefined;
|
||||
return {
|
||||
updateAvailable: readUpdateAvailableValue(payload?.updateAvailable),
|
||||
...(updateSchedule !== undefined
|
||||
? {
|
||||
updateSchedule,
|
||||
heldUpdateCampaignId: resolveHeldUpdateCampaignId(
|
||||
updateSchedule,
|
||||
current.heldUpdateCampaignId,
|
||||
),
|
||||
updateCampaignStatusHydrated: retainCampaignStatusHydration(
|
||||
current.updateSchedule,
|
||||
updateSchedule,
|
||||
current.updateCampaignStatusHydrated,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { property } from "lit/decorators.js";
|
||||
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
|
||||
import { DEFAULT_SIDEBAR_ENTRIES, type NavigationRouteId } from "../app-navigation.ts";
|
||||
import type { RouteId } from "../app-route-paths.ts";
|
||||
import { selectApplicationSession } from "../app/agent-selection.ts";
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
import type { CatalogOpenTarget } from "../app/settings.ts";
|
||||
import type { ThemeMode } from "../app/theme.ts";
|
||||
import type { UpdateProgress } from "../app/update-confirmation.ts";
|
||||
import type { ApplicationStatusBanner } from "../app/update-overlay-helpers.ts";
|
||||
import { readSessionMethodAccess, type SessionMethodAccess } from "../lib/session-method-access.ts";
|
||||
import { prepareSessionNavigationHandoff } from "../lib/sessions/navigation-handoff.ts";
|
||||
import { SESSION_NAVIGATION_KEY_PARAM } from "../lib/sessions/route-navigation.ts";
|
||||
@@ -49,21 +47,9 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement {
|
||||
@property({ attribute: false }) lobsterPetSounds = false;
|
||||
@property({ attribute: false }) gatewayVersion: string | null = null;
|
||||
@property({ attribute: false }) devGitBranch: string | null = null;
|
||||
@property({ attribute: false }) updateAvailable: UpdateAvailable | null = null;
|
||||
@property({ attribute: false }) updateSchedule: UpdateScheduleState | null = null;
|
||||
@property({ attribute: false }) heldUpdateCampaignId: string | null = null;
|
||||
@property({ attribute: false }) updateBusy = false;
|
||||
@property({ attribute: false }) updateStatusBanner: ApplicationStatusBanner | null = null;
|
||||
@property({ attribute: false }) watchUpdateProgress:
|
||||
| ((listener: (progress: UpdateProgress) => void) => () => void)
|
||||
| undefined = undefined;
|
||||
@property({ attribute: false }) canUpdate = false;
|
||||
@property({ attribute: false }) canHoldUpdate = false;
|
||||
@property({ attribute: false }) onUpdate: () => void = () => undefined;
|
||||
@property({ attribute: false }) refreshRequired = false;
|
||||
@property({ attribute: false }) onRefresh: () => void = () => undefined;
|
||||
@property({ attribute: false }) onHoldUpdate: () => Promise<boolean> = async () => false;
|
||||
@property({ attribute: false }) onReviewUpdate: () => void = () => undefined;
|
||||
@property({ attribute: false }) onOpenApprovals?: () => void;
|
||||
@property({ attribute: false }) onRetryConnect?: () => void;
|
||||
@property({ attribute: false }) onOpenNewSession?: (
|
||||
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
} from "../app-navigation.ts";
|
||||
import { isRouteId, isSessionRouteId, pathForRoute } from "../app-route-paths.ts";
|
||||
import { resolveControlUiAuthToken } from "../app/control-ui-auth.ts";
|
||||
import { hasNativeUpdateBridge } from "../app/native-link-routing.ts";
|
||||
import { isNativeWebChromeHost } from "../app/native-web-chrome.ts";
|
||||
import { confirmAndStartUpdate } from "../app/update-confirmation.ts";
|
||||
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
|
||||
import { CONTROL_UI_BUILD_INFO } from "../build-info.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
@@ -51,7 +49,6 @@ import { formatSidebarBuildSubtitle } from "./sidebar-build-chip-format.ts";
|
||||
type AppSidebarRenderHost = AppSidebarSessionNavigationElement & {
|
||||
activePluginTabId: string;
|
||||
activeWorkboardBoardId: string;
|
||||
nativeUpdateDeclined: boolean;
|
||||
offline: boolean;
|
||||
getRouteSessionKey(): string;
|
||||
renderPinnedSidebarSession(session: SidebarRecentSession): unknown;
|
||||
@@ -370,16 +367,8 @@ export function renderAppSidebarFooterBar(host: AppSidebarRenderHost) {
|
||||
: gateway
|
||||
? `${gateway.name}${gatewayPrimaryTag ? `, ${gatewayPrimaryTag}` : ""}`
|
||||
: buildSubtitle;
|
||||
const availableUpdate = host.updateAvailable;
|
||||
const showUpdate = availableUpdate !== null;
|
||||
const updateBusy = host.updateBusy || host.updateSchedule?.campaign?.state === "applying";
|
||||
const showInbox = true;
|
||||
return html`
|
||||
<div
|
||||
class="sidebar-footer-bar ${showInbox && showUpdate
|
||||
? "sidebar-footer-bar--two-actions"
|
||||
: "sidebar-footer-bar--one-action"}"
|
||||
>
|
||||
<div class="sidebar-footer-bar sidebar-footer-bar--one-action">
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-identity-card"
|
||||
@@ -422,37 +411,7 @@ export function renderAppSidebarFooterBar(host: AppSidebarRenderHost) {
|
||||
<span class="sidebar-identity-card__status" role="status" aria-live="polite"
|
||||
>${host.offline ? t("connection.reconnecting") : ""}</span
|
||||
>
|
||||
${showInbox || showUpdate
|
||||
? html`<span class="sidebar-footer-actions">
|
||||
${showInbox ? renderAppSidebarAttention(host) : nothing}
|
||||
${showUpdate
|
||||
? html`<span class="sidebar-footer-update-slot">
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-footer-update"
|
||||
aria-label=${t("updates.sidebar.availableTitle")}
|
||||
?disabled=${updateBusy || !host.canUpdate}
|
||||
@click=${() => {
|
||||
void confirmAndStartUpdate({
|
||||
updateAvailable: availableUpdate,
|
||||
updateSchedule: host.updateSchedule,
|
||||
viaNativeApp: !host.nativeUpdateDeclined && hasNativeUpdateBridge(),
|
||||
startGatewayUpdate: host.onUpdate,
|
||||
...(host.watchUpdateProgress
|
||||
? { watchUpdateProgress: host.watchUpdateProgress }
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span class="sidebar-footer-update__icon" aria-hidden="true"
|
||||
>${icons.download}</span
|
||||
>
|
||||
<span class="sidebar-footer-update__label">${t("updates.sidebar.action")}</span>
|
||||
</button>
|
||||
</span>`
|
||||
: nothing}
|
||||
</span>`
|
||||
: nothing}
|
||||
<span class="sidebar-footer-actions">${renderAppSidebarAttention(host)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,6 @@ import type {
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import { isSessionRouteId, pathForRoute } from "../app-route-paths.ts";
|
||||
import {
|
||||
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
|
||||
NATIVE_UPDATE_DECLINED_EVENT,
|
||||
} from "../app/native-link-routing.ts";
|
||||
import { beginNativeWindowDragFromTopInset } from "../app/native-window-drag.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { BoardAvailabilityController } from "../lib/board/availability-controller.ts";
|
||||
@@ -81,7 +77,6 @@ const sidebarChromeImport = createIdleImport(() =>
|
||||
class AppSidebar extends AppSidebarSessionNavigationElement implements SessionListHost {
|
||||
@state() sidebarNarrationLines: ReadonlyMap<string, string> = new Map();
|
||||
@state() sidebarObserverDigests: ReadonlyMap<string, SessionObserverDigest> = new Map();
|
||||
@state() nativeUpdateDeclined = false;
|
||||
|
||||
override readonly sessionOrganizer = new SessionOrganizerController(this);
|
||||
override readonly sidebarMenus = new SidebarMenusController(this);
|
||||
@@ -216,11 +211,6 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
|
||||
|
||||
override disconnectedCallback() {
|
||||
window.removeEventListener("openclaw:native-gateways-changed", this.nativeGatewaysChanged);
|
||||
window.removeEventListener(
|
||||
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
|
||||
this.handleNativeUpdateAvailabilityChanged,
|
||||
);
|
||||
window.removeEventListener(NATIVE_UPDATE_DECLINED_EVENT, this.handleNativeUpdateDeclined);
|
||||
window.removeEventListener(
|
||||
SIDEBAR_HIDDEN_SESSION_CATALOGS_CHANGED_EVENT,
|
||||
this.hiddenSessionCatalogsChanged,
|
||||
@@ -325,13 +315,7 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.nativeUpdateDeclined = false;
|
||||
window.addEventListener("openclaw:native-gateways-changed", this.nativeGatewaysChanged);
|
||||
window.addEventListener(
|
||||
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
|
||||
this.handleNativeUpdateAvailabilityChanged,
|
||||
);
|
||||
window.addEventListener(NATIVE_UPDATE_DECLINED_EVENT, this.handleNativeUpdateDeclined);
|
||||
this.hiddenSessionCatalogsChanged();
|
||||
window.addEventListener(
|
||||
SIDEBAR_HIDDEN_SESSION_CATALOGS_CHANGED_EVENT,
|
||||
@@ -343,28 +327,6 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
|
||||
this.catalogRendererImport.schedule();
|
||||
}
|
||||
|
||||
private readonly handleNativeUpdateDeclined = () => {
|
||||
if (this.nativeUpdateDeclined) {
|
||||
return;
|
||||
}
|
||||
this.nativeUpdateDeclined = true;
|
||||
const campaign = this.updateSchedule?.campaign;
|
||||
const updateBusy = this.updateBusy || campaign?.state === "applying";
|
||||
if (
|
||||
(this.updateAvailable || campaign) &&
|
||||
!updateBusy &&
|
||||
this.canUpdate &&
|
||||
!this.refreshRequired
|
||||
) {
|
||||
this.onUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly handleNativeUpdateAvailabilityChanged = () => {
|
||||
this.nativeUpdateDeclined = false;
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
protected override firstUpdated() {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => this.classList.add("sidebar-r")));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import "../test-helpers/load-styles.ts";
|
||||
import "../styles/sidebar-issues.css";
|
||||
import "./web-awesome-tabs.ts";
|
||||
|
||||
afterEach(() => document.body.replaceChildren());
|
||||
|
||||
describe.runIf("__vitest_browser__" in globalThis)("Inbox panel layout", () => {
|
||||
it("keeps tabs compact and item rails flush with the scrollport", async () => {
|
||||
const fixture = document.createElement("section");
|
||||
fixture.className = "sidebar-issues-panel";
|
||||
fixture.style.position = "static";
|
||||
fixture.style.width = "390px";
|
||||
fixture.style.height = "220px";
|
||||
fixture.innerHTML = `
|
||||
<wa-tab-group class="sidebar-issues-panel__tabs" without-scroll-controls>
|
||||
${["All", "Approvals", "Automations", "System"]
|
||||
.map(
|
||||
(label, index) => `<wa-tab
|
||||
slot="nav"
|
||||
class="sidebar-issues-panel__tab"
|
||||
panel="tab-${index}"
|
||||
${index === 0 ? "active" : ""}
|
||||
><span>${label}</span><span class="sidebar-issues-panel__tab-count">${index}</span></wa-tab>`,
|
||||
)
|
||||
.join("")}
|
||||
</wa-tab-group>
|
||||
<div class="sidebar-issues-panel__list-wrap">
|
||||
<div class="sidebar-issues-panel__list">
|
||||
${Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) => `<div data-attention-kind="cronFailed">
|
||||
<div class="sidebar-issues-panel__summary">Inbox item ${index}</div>
|
||||
</div>`,
|
||||
).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.append(fixture);
|
||||
|
||||
await customElements.whenDefined("wa-tab-group");
|
||||
const group = fixture.querySelector<HTMLElement & { updateComplete: Promise<unknown> }>(
|
||||
".sidebar-issues-panel__tabs",
|
||||
);
|
||||
const header = document.createElement("header");
|
||||
header.className = "sidebar-issues-panel__header";
|
||||
fixture.prepend(header);
|
||||
const tab = fixture.querySelector<HTMLElement & { updateComplete: Promise<unknown> }>(
|
||||
".sidebar-issues-panel__tab",
|
||||
);
|
||||
expect(group).not.toBeNull();
|
||||
expect(tab).not.toBeNull();
|
||||
await group?.updateComplete;
|
||||
await tab?.updateComplete;
|
||||
|
||||
const base = tab?.shadowRoot?.querySelector<HTMLElement>("[part='base']");
|
||||
const label = tab?.querySelector<HTMLElement>("span:first-child");
|
||||
const count = tab?.querySelector<HTMLElement>(".sidebar-issues-panel__tab-count");
|
||||
const list = fixture.querySelector<HTMLElement>(".sidebar-issues-panel__list");
|
||||
const item = fixture.querySelector<HTMLElement>("[data-attention-kind]");
|
||||
const summary = fixture.querySelector<HTMLElement>(".sidebar-issues-panel__summary");
|
||||
|
||||
expect(group?.scrollWidth).toBe(group?.clientWidth);
|
||||
expect(getComputedStyle(group!).overflowX).toBe("hidden");
|
||||
expect(getComputedStyle(group!).backgroundColor).toBe(getComputedStyle(header).backgroundColor);
|
||||
expect(getComputedStyle(group!).backgroundColor).not.toBe(
|
||||
getComputedStyle(list!).backgroundColor,
|
||||
);
|
||||
expect(getComputedStyle(group!).borderBottomWidth).toBe("1px");
|
||||
expect(getComputedStyle(base!).padding).toBe("4px 8px");
|
||||
expect(
|
||||
count!.getBoundingClientRect().left - label!.getBoundingClientRect().right,
|
||||
).toBeGreaterThan(4);
|
||||
expect(getComputedStyle(summary!).paddingBlock).toBe("8px");
|
||||
expect(item!.getBoundingClientRect().right).toBeCloseTo(list!.getBoundingClientRect().right, 1);
|
||||
});
|
||||
});
|
||||
@@ -53,8 +53,10 @@ function cronListResponse(jobs: CronJob[]): CronJobsListResult {
|
||||
}
|
||||
|
||||
type SidebarAttentionElement = HTMLElement & {
|
||||
context: ApplicationContext;
|
||||
updateComplete: Promise<boolean>;
|
||||
cronJobs: CronJob[];
|
||||
hasUpdateSurface(): boolean;
|
||||
modelAuthStatus: ModelAuthStatusResult | null;
|
||||
loadedAtMs: number;
|
||||
};
|
||||
@@ -477,6 +479,55 @@ describe("sidebar attention refresh ownership", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("update attention", () => {
|
||||
it("hides an unhydrated campaign only while update status can be polled", () => {
|
||||
const overlaySnapshot = {
|
||||
updateAvailable: {
|
||||
currentVersion: "2026.8.1",
|
||||
latestVersion: "2026.8.1",
|
||||
channel: "dev",
|
||||
commitsBehind: 2,
|
||||
},
|
||||
updateSchedule: {
|
||||
channel: "dev",
|
||||
autoEnabled: true,
|
||||
campaign: {
|
||||
id: "campaign-1",
|
||||
state: "waiting-for-idle",
|
||||
announcedAtMs: 1_000,
|
||||
forceAtMs: 901_000,
|
||||
updatedAtMs: 1_000,
|
||||
},
|
||||
},
|
||||
updateCampaignStatusHydrated: false,
|
||||
updateRunning: false,
|
||||
updateStatusBanner: null,
|
||||
};
|
||||
const gatewaySnapshot = {
|
||||
client: {} as GatewayBrowserClient,
|
||||
phase: "connected" as const,
|
||||
hello: {
|
||||
auth: { role: "operator", scopes: ["operator.admin"] },
|
||||
features: { methods: ["update.status"] },
|
||||
},
|
||||
};
|
||||
const element = document.createElement("openclaw-sidebar-attention") as SidebarAttentionElement;
|
||||
element.context = {
|
||||
gateway: { snapshot: gatewaySnapshot },
|
||||
overlays: { snapshot: overlaySnapshot },
|
||||
} as unknown as ApplicationContext;
|
||||
|
||||
expect(element.hasUpdateSurface()).toBe(false);
|
||||
|
||||
gatewaySnapshot.hello.auth.scopes = ["operator.read"];
|
||||
expect(element.hasUpdateSurface()).toBe(true);
|
||||
|
||||
gatewaySnapshot.hello.auth.scopes = ["operator.admin"];
|
||||
overlaySnapshot.updateCampaignStatusHydrated = true;
|
||||
expect(element.hasUpdateSurface()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneDismissals", () => {
|
||||
const chip = (kind: SidebarAttentionKind, signature: string) => ({ kind, signature });
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ import type { CronJob, ModelAuthStatusResult } from "../api/types.ts";
|
||||
import type { NavigationRouteId } from "../app-navigation.ts";
|
||||
import { applicationContext, type ApplicationContext } from "../app/context.ts";
|
||||
import type { ExecApprovalDecision, ExecApprovalRequest } from "../app/exec-approval.ts";
|
||||
import {
|
||||
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
|
||||
NATIVE_UPDATE_DECLINED_EVENT,
|
||||
} from "../app/native-link-routing.ts";
|
||||
import type { UpdateProgress } from "../app/update-confirmation.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { createInitialCronState, loadCronJobsPage } from "../lib/cron/index.ts";
|
||||
@@ -75,6 +79,7 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
private panelTrigger: HTMLElement | null = null;
|
||||
private panelRenderer: SidebarAttentionPanelRenderer | null = null;
|
||||
private panelLoad: Promise<SidebarAttentionPanelRenderer> | null = null;
|
||||
private nativeUpdateDeclined = false;
|
||||
|
||||
private readonly loadTask = new Task(this, {
|
||||
autoRun: false,
|
||||
@@ -197,14 +202,25 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.nativeUpdateDeclined = false;
|
||||
document.addEventListener("visibilitychange", this.refreshIfStale);
|
||||
globalThis.addEventListener("storage", this.syncDismissalsFromStorage);
|
||||
window.addEventListener(
|
||||
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
|
||||
this.handleNativeUpdateAvailabilityChanged,
|
||||
);
|
||||
window.addEventListener(NATIVE_UPDATE_DECLINED_EVENT, this.handleNativeUpdateDeclined);
|
||||
this.idleRefreshTimer = globalThis.setInterval(this.refreshIfStale, IDLE_REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
document.removeEventListener("visibilitychange", this.refreshIfStale);
|
||||
globalThis.removeEventListener("storage", this.syncDismissalsFromStorage);
|
||||
window.removeEventListener(
|
||||
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
|
||||
this.handleNativeUpdateAvailabilityChanged,
|
||||
);
|
||||
window.removeEventListener(NATIVE_UPDATE_DECLINED_EVENT, this.handleNativeUpdateDeclined);
|
||||
document.removeEventListener("pointerdown", this.closeOnOutsidePointer, true);
|
||||
if (this.idleRefreshTimer !== null) {
|
||||
globalThis.clearInterval(this.idleRefreshTimer);
|
||||
@@ -219,6 +235,32 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private readonly handleNativeUpdateAvailabilityChanged = () => {
|
||||
this.nativeUpdateDeclined = false;
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
// This element outlives the lazy panel, so a confirmed native handoff can
|
||||
// always continue through the Gateway when the host declines it.
|
||||
private readonly handleNativeUpdateDeclined = () => {
|
||||
if (this.nativeUpdateDeclined) {
|
||||
return;
|
||||
}
|
||||
this.nativeUpdateDeclined = true;
|
||||
const snapshot = this.context?.overlays.snapshot;
|
||||
const campaign = snapshot?.updateSchedule?.campaign;
|
||||
const busy = snapshot?.updateRunning || campaign?.state === "applying";
|
||||
if (
|
||||
snapshot &&
|
||||
(snapshot.updateAvailable || campaign) &&
|
||||
!busy &&
|
||||
!snapshot.controlUiRefreshRequired &&
|
||||
canCallGatewayMethod(this.context?.gateway.snapshot, "update.run", "operator.admin")
|
||||
) {
|
||||
void this.context?.overlays.runUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
protected override willUpdate(changed: PropertyValues<this>) {
|
||||
if (changed.has("activeRouteId") && changed.get("activeRouteId") !== undefined) {
|
||||
this.closePanel(false);
|
||||
@@ -318,8 +360,20 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
|
||||
private hasUpdateSurface(): boolean {
|
||||
const snapshot = this.context?.overlays.snapshot;
|
||||
if (!snapshot) {
|
||||
return false;
|
||||
}
|
||||
const campaign = snapshot.updateSchedule?.campaign;
|
||||
const canHydrateCampaign = canCallGatewayMethod(
|
||||
this.context?.gateway.snapshot,
|
||||
"update.status",
|
||||
"operator.admin",
|
||||
);
|
||||
if (campaign && !snapshot.updateCampaignStatusHydrated && canHydrateCampaign) {
|
||||
return Boolean(snapshot.updateRunning || snapshot.updateStatusBanner);
|
||||
}
|
||||
return Boolean(
|
||||
snapshot?.updateRunning || snapshot?.updateStatusBanner || snapshot?.updateSchedule?.campaign,
|
||||
snapshot.updateRunning || snapshot.updateStatusBanner || snapshot.updateAvailable || campaign,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import "./sidebar-update-card.ts";
|
||||
type SidebarUpdateCardElement = HTMLElement & {
|
||||
updateAvailable: UpdateAvailable | null;
|
||||
updateSchedule: UpdateScheduleState | null;
|
||||
compact: boolean;
|
||||
heldUpdateCampaignId: string | null;
|
||||
updateBusy: boolean;
|
||||
canUpdate: boolean;
|
||||
@@ -138,7 +139,7 @@ describe("SidebarUpdateCard", () => {
|
||||
expect(onUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("narrates the whole update after the Gateway drops its metadata", async () => {
|
||||
it("renders an available update and narrates it after the Gateway drops its metadata", async () => {
|
||||
const element = await mount(
|
||||
{ currentVersion: "1.0.0", latestVersion: "1.0.0", channel: "dev", commitsBehind: 246 },
|
||||
{
|
||||
@@ -152,7 +153,9 @@ describe("SidebarUpdateCard", () => {
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(element.querySelector(".sidebar-update-card")).toBeNull();
|
||||
expect(element.querySelector(".sidebar-update-card__action")?.textContent).toContain(
|
||||
"246 commits behind",
|
||||
);
|
||||
|
||||
element.updateBusy = true;
|
||||
await element.updateComplete;
|
||||
@@ -166,6 +169,23 @@ describe("SidebarUpdateCard", () => {
|
||||
expect(element.textContent).toContain("Updating Gateway…");
|
||||
});
|
||||
|
||||
it("keeps an available update actionable inside the compact Inbox row", async () => {
|
||||
const element = await mount({
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: "2.0.0",
|
||||
channel: "stable",
|
||||
});
|
||||
element.compact = true;
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.querySelector(".sidebar-issues-panel__entity")?.textContent).toBe(
|
||||
"Update available",
|
||||
);
|
||||
expect(element.querySelector(".sidebar-update-card__action")?.textContent).toContain(
|
||||
"Update Gateway",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a quiet live countdown and stops ticking on disconnect", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
|
||||
@@ -139,6 +139,16 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
|
||||
this.holdingCampaignId = null;
|
||||
};
|
||||
|
||||
private hasAvailableUpdate() {
|
||||
const update = this.updateAvailable;
|
||||
const gitTarget = this.updateSchedule?.target;
|
||||
return (
|
||||
(update !== null && update.latestVersion !== update.currentVersion) ||
|
||||
(update?.commitsBehind !== undefined && update.commitsBehind > 0) ||
|
||||
(gitTarget?.kind === "git" && gitTarget.commitsBehind > 0)
|
||||
);
|
||||
}
|
||||
|
||||
private compactSummary() {
|
||||
if (this.refreshRequired) {
|
||||
return {
|
||||
@@ -151,7 +161,7 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
|
||||
const campaign = this.updateSchedule?.campaign;
|
||||
const busy = this.updateBusy || campaign?.state === "applying";
|
||||
const statusBanner = this.statusBanner;
|
||||
if (!campaign && !busy && !statusBanner) {
|
||||
if (!campaign && !busy && !statusBanner && !this.hasAvailableUpdate()) {
|
||||
return null;
|
||||
}
|
||||
const targetLabel = formatUpdateTargetLabel(this.updateSchedule, this.updateAvailable);
|
||||
@@ -298,7 +308,7 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
|
||||
// metadata while it restarts, and the card must not vanish or fall back to
|
||||
// the stale "update available" call to action mid-install.
|
||||
const statusBanner = this.statusBanner;
|
||||
if (!campaign && !busy && !statusBanner) {
|
||||
if (!campaign && !busy && !statusBanner && !this.hasAvailableUpdate()) {
|
||||
return nothing;
|
||||
}
|
||||
const title = this.nativeUpdateAvailable
|
||||
@@ -330,7 +340,7 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
|
||||
);
|
||||
// An outcome with nothing left to act on is the whole card: re-offering an
|
||||
// update the operator just ran would bury the reason it failed.
|
||||
const actionable = Boolean(campaign || busy || (update && (hasVersionUpdate || hasGitUpdate)));
|
||||
const actionable = Boolean(campaign || busy || hasVersionUpdate || hasGitUpdate);
|
||||
return html`
|
||||
<div
|
||||
class="sidebar-update-card"
|
||||
|
||||
@@ -844,10 +844,13 @@ suite.define(() => {
|
||||
});
|
||||
|
||||
const sidebar = page.locator("openclaw-app-sidebar");
|
||||
const sidebarUpdate = sidebar.locator(".sidebar-footer-update");
|
||||
const sidebarUpdate = sidebar.locator(
|
||||
'openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]',
|
||||
);
|
||||
const sidebarAutomation = sidebar.locator('[data-attention-kind="cronFailed"]');
|
||||
await expect.poll(() => sidebarUpdate.count()).toBe(1);
|
||||
await expect.poll(() => sidebar.locator(".sidebar-footer-update").count()).toBe(0);
|
||||
await sidebar.locator(".sidebar-issues-button").click();
|
||||
await expect.poll(() => sidebarUpdate.count()).toBe(1);
|
||||
await expect.poll(() => sidebarAutomation.count()).toBe(1);
|
||||
await captureUiProof(page, "08-desktop-attention-unchanged.png");
|
||||
await sidebar.locator(".sidebar-issues-button").click();
|
||||
@@ -873,8 +876,8 @@ suite.define(() => {
|
||||
await expect
|
||||
.poll(() => page.locator(".shell").getAttribute("class"))
|
||||
.toContain("shell--nav-drawer-open");
|
||||
await expect.poll(() => sidebarUpdate.isVisible()).toBe(true);
|
||||
await sidebar.locator(".sidebar-issues-button").click();
|
||||
await expect.poll(() => sidebarUpdate.isVisible()).toBe(true);
|
||||
await expect.poll(() => sidebarAutomation.isVisible()).toBe(true);
|
||||
await captureUiProof(page, "10-mobile-attention-drawer.png");
|
||||
|
||||
|
||||
@@ -18,7 +18,12 @@ const MANAGED_UPDATE_HANDOFF_RESPONSE = {
|
||||
} as const;
|
||||
|
||||
async function openUpdateConfirmation(page: Page): Promise<void> {
|
||||
await page.locator(".sidebar-footer-update:visible").click();
|
||||
await page.locator(".sidebar-issues-button").click();
|
||||
const updateIssue = page.locator(
|
||||
'openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]',
|
||||
);
|
||||
await updateIssue.locator("summary").click();
|
||||
await updateIssue.locator(".sidebar-update-card__action").click();
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
@@ -74,7 +79,7 @@ suite.define(() => {
|
||||
);
|
||||
await updateIssue.locator("summary").click();
|
||||
await updateIssue.locator(".sidebar-update-card__compact-reason").waitFor();
|
||||
expect(await page.locator(".sidebar-footer-update:visible").isEnabled()).toBe(true);
|
||||
expect(await page.locator(".sidebar-footer-update").count()).toBe(0);
|
||||
expect(pageErrors).toEqual([]);
|
||||
await page.screenshot({ path: path.join(artifactDir, "package-update-failure.png") });
|
||||
},
|
||||
@@ -132,7 +137,7 @@ suite.define(() => {
|
||||
{ exact: true },
|
||||
)
|
||||
.waitFor();
|
||||
expect(await page.locator(".sidebar-footer-update:visible").isEnabled()).toBe(false);
|
||||
expect(await page.locator(".sidebar-footer-update").count()).toBe(0);
|
||||
expect(pageErrors).toEqual([]);
|
||||
await page.screenshot({ path: path.join(artifactDir, "coalesced-restart-banner.png") });
|
||||
},
|
||||
@@ -288,6 +293,10 @@ suite.define(() => {
|
||||
).toEqual([{ type: "start-update" }]);
|
||||
expect(await gateway.getRequests("update.run")).toHaveLength(0);
|
||||
|
||||
// The confirmation closes the lazy Inbox. Recovery belongs to the
|
||||
// persistent attention owner, not the now-disconnected update card.
|
||||
await page.keyboard.press("Escape");
|
||||
await expect.poll(() => page.locator(".sidebar-issues-panel").count()).toBe(0);
|
||||
await page.evaluate(
|
||||
(eventName) => window.dispatchEvent(new CustomEvent(eventName)),
|
||||
NATIVE_UPDATE_DECLINED_EVENT,
|
||||
|
||||
@@ -27,6 +27,10 @@ function confirmationCopy(page: Page) {
|
||||
return page.locator("openclaw-modal-dialog");
|
||||
}
|
||||
|
||||
function confirmationDialog(page: Page) {
|
||||
return page.getByRole("dialog", { name: "Update Gateway", exact: true });
|
||||
}
|
||||
|
||||
async function openUpdateCard(page: Page, baseUrl: string, compact = false) {
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: { "update.run": UPDATE_RUN_RESPONSE },
|
||||
@@ -40,7 +44,12 @@ async function openUpdateCard(page: Page, baseUrl: string, compact = false) {
|
||||
await updateButton.waitFor({ timeout: 10_000 });
|
||||
return { compact, gateway, updateButton };
|
||||
}
|
||||
const updateButton = page.locator(".sidebar-footer-update:visible");
|
||||
await page.locator(".sidebar-issues-button").click();
|
||||
const updateIssue = page.locator(
|
||||
'openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]',
|
||||
);
|
||||
await updateIssue.locator("summary").click();
|
||||
const updateButton = updateIssue.locator(".sidebar-update-card__action");
|
||||
await updateButton.waitFor({ timeout: 10_000 });
|
||||
return { compact, gateway, updateButton };
|
||||
}
|
||||
@@ -65,7 +74,7 @@ suite.define(() => {
|
||||
|
||||
await openConfirmation(page, updateButton);
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
const dialog = confirmationDialog(page);
|
||||
await dialog.waitFor();
|
||||
expect(await dialog.getAttribute("aria-label")).toBe("Update Gateway");
|
||||
const dialogText = await confirmationCopy(page).textContent();
|
||||
@@ -108,7 +117,7 @@ suite.define(() => {
|
||||
compact,
|
||||
);
|
||||
await openConfirmation(page, updateButton, compact);
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await confirmationDialog(page).waitFor();
|
||||
expect(
|
||||
await confirmationCopy(page)
|
||||
.getByRole("button", { name: "Update and restart", exact: true })
|
||||
@@ -133,14 +142,14 @@ suite.define(() => {
|
||||
async ({ page }) => {
|
||||
const { gateway, updateButton } = await openUpdateCard(page, suite.server.baseUrl);
|
||||
await openConfirmation(page, updateButton);
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await confirmationDialog(page).waitFor();
|
||||
|
||||
if (dismiss === "Escape") {
|
||||
await page.keyboard.press("Escape");
|
||||
} else {
|
||||
await page.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
}
|
||||
await page.getByRole("dialog").waitFor({ state: "detached" });
|
||||
await confirmationDialog(page).waitFor({ state: "detached" });
|
||||
|
||||
expect(await gateway.getRequests("update.run")).toHaveLength(0);
|
||||
},
|
||||
@@ -155,7 +164,7 @@ suite.define(() => {
|
||||
|
||||
await updateButton.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const dialog = page.getByRole("dialog");
|
||||
const dialog = confirmationDialog(page);
|
||||
await dialog.waitFor();
|
||||
|
||||
// The keypress that opened the dialog lands on Cancel, never on the confirm action.
|
||||
@@ -192,7 +201,7 @@ suite.define(() => {
|
||||
async ({ page }) => {
|
||||
const { gateway, updateButton } = await openUpdateCard(page, suite.server.baseUrl);
|
||||
await openConfirmation(page, updateButton);
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await confirmationDialog(page).waitFor();
|
||||
|
||||
await confirmationCopy(page)
|
||||
.getByRole("button", { name: "Update and restart", exact: true })
|
||||
@@ -236,7 +245,7 @@ suite.define(() => {
|
||||
await gateway.emitGatewayEvent("update.available", { updateAvailable: UPDATE_AVAILABLE });
|
||||
await page.getByRole("button", { name: "Update now", exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
const dialog = confirmationDialog(page);
|
||||
await dialog.waitFor();
|
||||
expect(await confirmationCopy(page).textContent()).toContain(
|
||||
"Installed v1.0.0 · Available v2.0.0",
|
||||
|
||||
@@ -50,7 +50,12 @@ const HANDOFF_PENDING_SENTINEL = {
|
||||
};
|
||||
|
||||
async function openUpdateConfirmation(page: Page): Promise<void> {
|
||||
await page.locator(".sidebar-footer-update:visible").click();
|
||||
await page.locator(".sidebar-issues-button").click();
|
||||
const updateIssue = page.locator(
|
||||
'openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]',
|
||||
);
|
||||
await updateIssue.locator("summary").click();
|
||||
await updateIssue.locator(".sidebar-update-card__action").click();
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
|
||||
@@ -3288,10 +3288,6 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
|
||||
padding-inline-end: 44px;
|
||||
}
|
||||
|
||||
.sidebar-footer-bar--two-actions {
|
||||
padding-inline-end: 88px;
|
||||
}
|
||||
|
||||
.sidebar-footer-actions {
|
||||
position: absolute;
|
||||
inset-block-start: 50%;
|
||||
@@ -3299,7 +3295,6 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
@@ -3471,117 +3466,6 @@ openclaw-sidebar-attention {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sidebar-footer-update-slot {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 32px;
|
||||
transition:
|
||||
width var(--duration-fast) ease,
|
||||
flex-basis var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.sidebar-footer-update-slot:has(.sidebar-footer-update:hover:not(:disabled)) {
|
||||
width: 76px;
|
||||
flex-basis: 76px;
|
||||
transition-delay: var(--duration-slow);
|
||||
}
|
||||
|
||||
.sidebar-footer-update-slot:focus-within {
|
||||
width: 76px;
|
||||
flex-basis: 76px;
|
||||
}
|
||||
|
||||
.sidebar-footer-update-slot::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset-block: 0;
|
||||
inset-inline-end: 20px;
|
||||
width: 64px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to right, transparent, var(--sidebar-bg));
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.sidebar-footer-update-slot:has(.sidebar-footer-update:hover:not(:disabled))::before {
|
||||
opacity: 1;
|
||||
transition-delay: var(--duration-slow);
|
||||
}
|
||||
|
||||
.sidebar-footer-update-slot:focus-within::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-footer-update {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset-block-start: 2px;
|
||||
inset-inline-end: 2px;
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
padding: 0 7px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--inbox-attention);
|
||||
color: var(--inbox-attention-foreground);
|
||||
cursor: var(--cursor-action);
|
||||
transition:
|
||||
width var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.sidebar-footer-update:hover:not(:disabled) {
|
||||
width: 76px;
|
||||
background: var(--inbox-attention-hover);
|
||||
transition-delay: var(--duration-slow);
|
||||
}
|
||||
|
||||
.sidebar-footer-update:focus-visible {
|
||||
width: 76px;
|
||||
background: var(--inbox-attention-hover);
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.sidebar-footer-update:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.sidebar-footer-update__icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 14px;
|
||||
}
|
||||
|
||||
.sidebar-footer-update__icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.sidebar-footer-update__label {
|
||||
opacity: 0;
|
||||
font-size: var(--control-ui-text-xs);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: opacity var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.sidebar-footer-update:hover:not(:disabled) .sidebar-footer-update__label {
|
||||
opacity: 1;
|
||||
transition-delay: var(--duration-slow);
|
||||
}
|
||||
|
||||
.sidebar-footer-update:focus-visible .sidebar-footer-update__label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
openclaw-tooltip.sidebar-hover-tooltip {
|
||||
--openclaw-tooltip-max-width: min(340px, calc(100vw - 24px));
|
||||
--openclaw-tooltip-open-animation: openclaw-tooltip-hover-card-in 100ms var(--ease-out);
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.sidebar-issues-panel__heading {
|
||||
@@ -147,25 +148,29 @@
|
||||
}
|
||||
|
||||
.sidebar-issues-panel__tabs {
|
||||
display: flex;
|
||||
display: block;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border-block-end: 1px solid var(--border);
|
||||
background: var(--secondary);
|
||||
--indicator-color: transparent;
|
||||
--track-color: transparent;
|
||||
--track-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-issues-panel__tabs::part(tabs) {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
padding: 0 6px 8px;
|
||||
border-block-end: 1px solid var(--border-strong);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sidebar-issues-panel__tab {
|
||||
display: inline-flex;
|
||||
min-height: 32px;
|
||||
flex: 1 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: var(--cursor-action);
|
||||
@@ -175,17 +180,27 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-issues-panel__tab:hover {
|
||||
.sidebar-issues-panel__tab::part(base) {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.sidebar-issues-panel__tab:hover::part(base) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-issues-panel__tab[aria-selected="true"] {
|
||||
.sidebar-issues-panel__tab:is([active], [aria-selected="true"])::part(base) {
|
||||
background: color-mix(in srgb, var(--bg-hover) 92%, var(--text) 8%);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.sidebar-issues-panel__tab:focus-visible {
|
||||
.sidebar-issues-panel__tab:focus-visible::part(base) {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
@@ -213,7 +228,6 @@
|
||||
padding: 6px;
|
||||
overscroll-behavior: contain;
|
||||
scroll-padding-block: 6px 18px;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
@@ -321,7 +335,7 @@
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 12px 4px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 0;
|
||||
cursor: var(--cursor-action);
|
||||
list-style: none;
|
||||
|
||||
Reference in New Issue
Block a user