Skip to content

Commit e715789

Browse files
authored
feat(claw): detect image-tag-only updates and make upgrade one-click (#1355)
<!-- PR title format: type(scope): description — e.g., feat(auth): add SSO login --> <!-- Keep the title under 72 characters, use imperative mood, no trailing period. --> ## Summary - Show "Update available" badge when a newer image exists even without a CalVer bump (e.g. controller-only changes that produce a new img-{hash} tag) - The badge is now a clickable button that opens the "Redeploy or Upgrade" dialog with "Upgrade to latest" preselected - Version Pinning "Current Status" panel now displays current and latest image tags in a table layout - Version Pinning card restructured to a consistent two-column layout (pinning controls left, status right) with the pin button inline next to the version selector ## Verification - pnpm run typecheck — passes - pnpm run lint — passes - pnpm run format:check — passes - Verified imageTagDiffers is gated on hasVersionInfo so the badge cannot appear during loading states - Verified latestImageTag is prop-drilled to VersionPinCard rather than fetched independently (avoids implicit coupling via query cache) - Confirmed existing CalVer-based update detection is unchanged — new logic only fires as a fallback when CalVer matches ## Visual Changes - "Update available" badge — now clickable with hover state; tooltip shows the target image tag for image-only updates - Version Pinning panel — two-column layout: left column has description + version selector + pin button inline + reason field; right column has Current Status with "Following latest" pill, plus current/latest image tags in aligned table rows - Pin button — moved inline next to the version dropdown instead of being a standalone row <!-- If UI/visual behavior changed, add before/after screenshots in the table below. --> <!-- If there are no visual changes, replace the table with: N/A --> <img width="1082" height="104" alt="Screenshot 2026-03-20 at 4 38 46 PM" src="https://github.com/user-attachments/assets/d5991665-a33a-4b79-bbe3-1fc54afa8dbc" /> <img width="1073" height="409" alt="Screenshot 2026-03-20 at 4 39 06 PM" src="https://github.com/user-attachments/assets/1be77444-f67d-41d1-8279-2a1afc60452c" /> ## Reviewer Notes - The cross tab upgrade trigger uses a toggle flag pattern (upgradeRequested boolean lifted to ClawDashboard, consumed via useEffect in InstanceControls). This is documented with an inline comment. it won't re-fire if the flag is already true, which is fine for a single-click flow but worth knowing - VersionPinCard no longer calls useKiloClawLatestVersion() directly — it receives latestImageTag as a prop from SettingsTab which already has the data. Both are react-query hooks that would share cache, but prop drilling keeps the component more presentational - The ClawDashboard.tsx diff includes a whitespace-only reformatting of the popularity message string (formatter fix, no logic change)
2 parents d82f719 + 3a69247 commit e715789

4 files changed

Lines changed: 176 additions & 90 deletions

File tree

src/app/(app)/claw/components/ClawDashboard.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ export function ClawDashboard({
7575
const onSecretsChanged = useCallback((entryId: string) => {
7676
setDirtySecrets(prev => new Set([...prev, entryId]));
7777
}, []);
78+
const [upgradeRequested, setUpgradeRequested] = useState(false);
79+
const onRequestUpgrade = useCallback(() => setUpgradeRequested(true), []);
80+
const onUpgradeHandled = useCallback(() => setUpgradeRequested(false), []);
81+
7882
const onRedeploySuccess = useCallback(() => {
7983
setDirtySecrets(new Set());
8084
}, []);
@@ -236,6 +240,8 @@ export function ClawDashboard({
236240
status={instanceStatus}
237241
mutations={mutations}
238242
onRedeploySuccess={onRedeploySuccess}
243+
upgradeRequested={upgradeRequested}
244+
onUpgradeHandled={onUpgradeHandled}
239245
/>
240246
</CardContent>
241247
<Tabs defaultValue="instance">
@@ -276,6 +282,7 @@ export function ClawDashboard({
276282
mutations={mutations}
277283
onSecretsChanged={onSecretsChanged}
278284
dirtySecrets={dirtySecrets}
285+
onRequestUpgrade={onRequestUpgrade}
279286
/>
280287
</TabsContent>
281288
<TabsContent value="changelog" className="mt-0">

src/app/(app)/claw/components/InstanceControls.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { useState } from 'react';
3+
import { useEffect, useState } from 'react';
44
import { Cpu, HardDrive, Play, RefreshCw, RotateCw, Stethoscope } from 'lucide-react';
55
import { usePostHog } from 'posthog-js/react';
66
import { toast } from 'sonner';
@@ -37,10 +37,14 @@ export function InstanceControls({
3737
status,
3838
mutations,
3939
onRedeploySuccess,
40+
upgradeRequested,
41+
onUpgradeHandled,
4042
}: {
4143
status: KiloClawDashboardStatus;
4244
mutations: ClawMutations;
4345
onRedeploySuccess?: () => void;
46+
upgradeRequested?: boolean;
47+
onUpgradeHandled?: () => void;
4448
}) {
4549
const posthog = usePostHog();
4650
const isRunning = status.status === 'running';
@@ -57,6 +61,17 @@ export function InstanceControls({
5761
const [confirmRedeploy, setConfirmRedeploy] = useState(false);
5862
const [redeployMode, setRedeployMode] = useState<'redeploy' | 'upgrade'>('redeploy');
5963

64+
// Toggle-flag pattern: parent sets upgradeRequested=true, we open the dialog
65+
// with "upgrade" preselected, then immediately reset via onUpgradeHandled.
66+
// Safe for single-click flows; won't re-fire if already true (no state change).
67+
useEffect(() => {
68+
if (upgradeRequested) {
69+
setRedeployMode('upgrade');
70+
setConfirmRedeploy(true);
71+
onUpgradeHandled?.();
72+
}
73+
}, [upgradeRequested, onUpgradeHandled]);
74+
6075
return (
6176
<div>
6277
<div className="mb-4 flex items-start justify-between gap-4">

src/app/(app)/claw/components/SettingsTab.tsx

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -435,11 +435,13 @@ export function SettingsTab({
435435
mutations,
436436
onSecretsChanged,
437437
dirtySecrets,
438+
onRequestUpgrade,
438439
}: {
439440
status: KiloClawDashboardStatus;
440441
mutations: ClawMutations;
441442
onSecretsChanged?: (entryId: string) => void;
442443
dirtySecrets: Set<string>;
444+
onRequestUpgrade?: () => void;
443445
}) {
444446
const posthog = usePostHog();
445447
const { data: config } = useKiloClawConfig();
@@ -547,14 +549,27 @@ export function SettingsTab({
547549
!!latestAvailableVersion &&
548550
latestAvailableVersion !== trackedVersion &&
549551
calverAtLeast(latestAvailableVersion, trackedVersion);
550-
const updateAvailable =
551-
catalogNewerThanImage &&
552-
(!isModified ||
553-
(!!runningVersion &&
554-
calverAtLeast(latestAvailableVersion, runningVersion) &&
555-
latestAvailableVersion !== runningVersion));
556552
const isPinned = !!myPin;
557553
const hasVersionInfo = isRunning && trackedVersion && trackedVersion !== ':latest';
554+
// Only compare image tags when variants match — latestVersion is always
555+
// for the "default" variant, so skip for non-default instances to avoid
556+
// false "Update available" badges that would switch their variant.
557+
const variantsMatch =
558+
!status.imageVariant ||
559+
status.imageVariant === 'default' ||
560+
status.imageVariant === latestVersion?.variant;
561+
const imageTagDiffers =
562+
hasVersionInfo &&
563+
variantsMatch &&
564+
!!status.trackedImageTag &&
565+
!!latestVersion?.imageTag &&
566+
status.trackedImageTag !== latestVersion.imageTag;
567+
const updateAvailable = catalogNewerThanImage
568+
? !isModified ||
569+
(!!runningVersion &&
570+
calverAtLeast(latestAvailableVersion, runningVersion) &&
571+
latestAvailableVersion !== runningVersion)
572+
: imageTagDiffers;
558573

559574
return (
560575
<div className="flex flex-col gap-6">
@@ -607,17 +622,20 @@ export function SettingsTab({
607622
{updateAvailable && (
608623
<Tooltip>
609624
<TooltipTrigger asChild>
610-
<Badge
611-
variant="outline"
612-
className="border-orange-500/30 bg-orange-500/15 text-orange-400"
613-
>
614-
Update available
615-
</Badge>
625+
<button type="button" onClick={onRequestUpgrade} className="cursor-pointer">
626+
<Badge
627+
variant="outline"
628+
className="border-orange-500/30 bg-orange-500/15 text-orange-400 hover:bg-orange-500/25"
629+
>
630+
Update available
631+
</Badge>
632+
</button>
616633
</TooltipTrigger>
617634
<TooltipContent>
618635
<p>
619-
A newer OpenClaw version ({latestAvailableVersion}) is available —
620-
redeploy to upgrade
636+
{catalogNewerThanImage
637+
? `A newer OpenClaw version (${latestAvailableVersion}) is available — click to upgrade`
638+
: `A newer image (${latestVersion?.imageTag ?? 'unknown'}) is available — click to upgrade`}
621639
</p>
622640
</TooltipContent>
623641
</Tooltip>
@@ -652,7 +670,10 @@ export function SettingsTab({
652670
{/* Expandable version pinning */}
653671
{manageVersionOpen && (
654672
<div className="mt-4 border-t pt-4">
655-
<VersionPinCard />
673+
<VersionPinCard
674+
trackedImageTag={status.trackedImageTag}
675+
latestImageTag={variantsMatch ? (latestVersion?.imageTag ?? null) : null}
676+
/>
656677
</div>
657678
)}
658679
</div>

src/app/(app)/claw/components/VersionPinCard.tsx

Lines changed: 117 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ import {
1919
import { Label } from '@/components/ui/label';
2020
import { Textarea } from '@/components/ui/textarea';
2121

22-
export function VersionPinCard() {
22+
export function VersionPinCard({
23+
trackedImageTag,
24+
latestImageTag,
25+
}: {
26+
trackedImageTag: string | null;
27+
latestImageTag: string | null;
28+
}) {
2329
const { data: myPin, isLoading: pinLoading } = useKiloClawMyPin();
2430
const { data: versions, isLoading: versionsLoading } = useKiloClawAvailableVersions(0, 50);
2531
const mutations = useKiloClawMutations();
@@ -94,20 +100,76 @@ export function VersionPinCard() {
94100
<Pin className="size-4" />
95101
Version Pinning
96102
</h3>
97-
{/* Description + Current Status */}
98-
<div className="mb-6 grid grid-cols-2 items-start gap-6">
99-
{/* Left: Description + Info */}
100-
<div className="space-y-2">
101-
<p className="text-muted-foreground text-sm">
102-
Pin your instance to a specific OpenClaw version or follow the latest
103-
</p>
104-
<div className="text-muted-foreground flex items-start gap-1 text-xs">
105-
<Info className="mt-0.5 h-3 w-3 shrink-0" />
106-
<span>
107-
Pinning locks your instance to a specific version. You won&apos;t receive automatic
108-
updates until you unpin.
109-
</span>
103+
<div className="grid grid-cols-2 items-start gap-6">
104+
{/* Left: Description + Pinning Controls */}
105+
<div className="space-y-3">
106+
<div className="space-y-2">
107+
<p className="text-muted-foreground text-sm">
108+
Pin your instance to a specific OpenClaw version or follow the latest
109+
</p>
110+
<div className="text-muted-foreground flex items-start gap-1 text-xs">
111+
<Info className="mt-0.5 h-3 w-3 shrink-0" />
112+
<span>
113+
Pinning locks your instance to a specific version. You won&apos;t receive automatic
114+
updates until you unpin.
115+
</span>
116+
</div>
110117
</div>
118+
119+
{!isPinned ? (
120+
<div className="space-y-3">
121+
<div className="space-y-2">
122+
<Label htmlFor="version-select" className="text-sm">
123+
Select Version
124+
</Label>
125+
<div className="flex items-center gap-2">
126+
<Select value={selectedImageTag} onValueChange={setSelectedImageTag}>
127+
<SelectTrigger id="version-select">
128+
<SelectValue placeholder="Choose a version to pin..." />
129+
</SelectTrigger>
130+
<SelectContent>
131+
{versions?.items.map(version => (
132+
<SelectItem key={version.image_tag} value={version.image_tag}>
133+
<div className="flex flex-col">
134+
<span className="font-medium">
135+
{version.openclaw_version} / {version.variant}
136+
</span>
137+
<span
138+
className="text-muted-foreground text-xs"
139+
title={version.image_tag}
140+
>
141+
{truncateTag(version.image_tag)}
142+
</span>
143+
</div>
144+
</SelectItem>
145+
))}
146+
</SelectContent>
147+
</Select>
148+
<Button
149+
onClick={handlePin}
150+
disabled={!selectedImageTag || isPinning}
151+
size="sm"
152+
className="shrink-0"
153+
>
154+
{isPinning ? 'Pinning...' : 'Pin to this version'}
155+
</Button>
156+
</div>
157+
</div>
158+
<div className="space-y-2">
159+
<Label htmlFor="pin-reason" className="text-sm">
160+
Reason (optional)
161+
</Label>
162+
<Textarea
163+
id="pin-reason"
164+
placeholder="Why are you pinning to this version?"
165+
value={reason}
166+
onChange={e => setReason(e.target.value)}
167+
rows={3}
168+
maxLength={500}
169+
/>
170+
</div>
171+
</div>
172+
) : null}
111173
</div>
112174

113175
{/* Right: Current Status */}
@@ -168,70 +230,51 @@ export function VersionPinCard() {
168230
</div>
169231
</div>
170232
) : (
171-
<div className="flex items-center gap-2">
172-
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-100">
173-
Following latest
174-
</span>
175-
<span className="text-muted-foreground text-xs">
176-
Automatically uses newest version
177-
</span>
233+
<div className="space-y-3">
234+
<div className="flex items-center gap-2">
235+
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-100">
236+
Following latest
237+
</span>
238+
<span className="text-muted-foreground text-xs">
239+
Automatically uses newest version
240+
</span>
241+
</div>
242+
{(trackedImageTag || latestImageTag) && (
243+
<table className="text-sm">
244+
<tbody>
245+
{trackedImageTag && (
246+
<tr>
247+
<td className="text-muted-foreground pr-3 align-top">Current image</td>
248+
<td>
249+
<code
250+
className="bg-muted rounded px-1.5 py-0.5 text-xs"
251+
title={trackedImageTag}
252+
>
253+
{truncateTag(trackedImageTag)}
254+
</code>
255+
</td>
256+
</tr>
257+
)}
258+
{latestImageTag && (
259+
<tr>
260+
<td className="text-muted-foreground pr-3 pt-1 align-top">Latest image</td>
261+
<td className="pt-1">
262+
<code
263+
className="bg-muted rounded px-1.5 py-0.5 text-xs"
264+
title={latestImageTag}
265+
>
266+
{truncateTag(latestImageTag)}
267+
</code>
268+
</td>
269+
</tr>
270+
)}
271+
</tbody>
272+
</table>
273+
)}
178274
</div>
179275
)}
180276
</div>
181277
</div>
182-
183-
{/* Row 3: Pin/Unpin Controls */}
184-
{!isPinned ? (
185-
<div className="grid grid-cols-2 items-start gap-6">
186-
{/* Left Column: Version Selector + Reason */}
187-
<div className="space-y-3">
188-
<div className="space-y-2">
189-
<Label htmlFor="version-select" className="text-sm">
190-
Select Version
191-
</Label>
192-
<Select value={selectedImageTag} onValueChange={setSelectedImageTag}>
193-
<SelectTrigger id="version-select">
194-
<SelectValue placeholder="Choose a version to pin..." />
195-
</SelectTrigger>
196-
<SelectContent>
197-
{versions?.items.map(version => (
198-
<SelectItem key={version.image_tag} value={version.image_tag}>
199-
<div className="flex flex-col">
200-
<span className="font-medium">
201-
{version.openclaw_version} / {version.variant}
202-
</span>
203-
<span className="text-muted-foreground text-xs" title={version.image_tag}>
204-
{truncateTag(version.image_tag)}
205-
</span>
206-
</div>
207-
</SelectItem>
208-
))}
209-
</SelectContent>
210-
</Select>
211-
</div>
212-
<div className="space-y-2">
213-
<Label htmlFor="pin-reason" className="text-sm">
214-
Reason (optional)
215-
</Label>
216-
<Textarea
217-
id="pin-reason"
218-
placeholder="Why are you pinning to this version?"
219-
value={reason}
220-
onChange={e => setReason(e.target.value)}
221-
rows={3}
222-
maxLength={500}
223-
/>
224-
</div>
225-
</div>
226-
227-
{/* Right Column: Pin Button */}
228-
<div>
229-
<Button onClick={handlePin} disabled={!selectedImageTag || isPinning} size="sm">
230-
{isPinning ? 'Pinning...' : 'Pin to this version'}
231-
</Button>
232-
</div>
233-
</div>
234-
) : null}
235278
</div>
236279
);
237280
}

0 commit comments

Comments
 (0)