Skip to content

Commit b4190e1

Browse files
authored
fix(settings): redesign About Updates as hero card with check animation (#145)
* fix(settings): redesign About → Updates as hero card with check animation Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * refactor(settings): extract DrawCheckIcon shared component Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> --------- Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
1 parent 42c4cd2 commit b4190e1

6 files changed

Lines changed: 364 additions & 63 deletions

File tree

src/components/DrawCheckIcon.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import styles from '../styles/settings.module.css';
2+
3+
/**
4+
* Two-stage success animation: draws a green ring around a 16x16 frame
5+
* (~550 ms) then stamps a checkmark inside (~300 ms after a small delay).
6+
*
7+
* Used by settings actions that may run long enough to need feedback:
8+
* the AI tab "Unload now" pill while VRAM eviction is in flight, and the
9+
* About tab "Check for updates" button while the manifest poll resolves.
10+
* The CSS keyframes live in `settings.module.css`
11+
* (`keepWarmCircleAnim` + `keepWarmCheckAnim`).
12+
*/
13+
export function DrawCheckIcon() {
14+
return (
15+
<svg
16+
viewBox="0 0 16 16"
17+
width="11"
18+
height="11"
19+
fill="none"
20+
aria-hidden="true"
21+
>
22+
<circle
23+
cx="8"
24+
cy="8"
25+
r="7"
26+
stroke="#5ec98a"
27+
strokeWidth="1.6"
28+
className={styles.keepWarmCircleAnim}
29+
transform="rotate(-90 8 8)"
30+
/>
31+
<path
32+
d="M4.5 8.5L7 11L12 5.5"
33+
stroke="#5ec98a"
34+
strokeWidth="1.6"
35+
strokeLinecap="round"
36+
strokeLinejoin="round"
37+
className={styles.keepWarmCheckAnim}
38+
/>
39+
</svg>
40+
);
41+
}

src/settings/tabs/AboutTab.test.tsx

Lines changed: 126 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
act,
66
waitFor,
77
} from '@testing-library/react';
8-
import { describe, it, expect, beforeEach } from 'vitest';
8+
import { describe, it, expect, beforeEach, vi } from 'vitest';
99
import { invoke } from '@tauri-apps/api/core';
1010
import { AboutTab } from './AboutTab';
1111

@@ -42,18 +42,21 @@ beforeEach(() => {
4242
});
4343

4444
describe('AboutTab', () => {
45-
it('renders the Updates section with Current version and Last checked rows', async () => {
45+
it('renders the Updates hero showing up-to-date status and a check button', async () => {
4646
render(<AboutTab {...SAMPLE_PROPS} />);
47-
await waitFor(() => screen.getByText('Current version'));
48-
expect(screen.getByText('Last checked')).toBeInTheDocument();
47+
await waitFor(() =>
48+
expect(screen.getByText('Thuki is up to date')).toBeInTheDocument(),
49+
);
4950
expect(
50-
screen.getByRole('button', { name: /check now/i }),
51+
screen.getByRole('button', { name: /check for updates/i }),
5152
).toBeInTheDocument();
5253
});
5354

54-
it('shows Never for last checked when last_check_at_unix is null', async () => {
55+
it('shows "Never checked for updates" when last_check_at_unix is null', async () => {
5556
render(<AboutTab {...SAMPLE_PROPS} />);
56-
await waitFor(() => expect(screen.getByText('Never')).toBeInTheDocument());
57+
await waitFor(() =>
58+
expect(screen.getByText('Never checked for updates')).toBeInTheDocument(),
59+
);
5760
});
5861

5962
it('shows relative time when last_check_at_unix is set', async () => {
@@ -70,21 +73,133 @@ describe('AboutTab', () => {
7073
});
7174
render(<AboutTab {...SAMPLE_PROPS} />);
7275
await waitFor(() =>
73-
expect(screen.getByText('2 minutes ago')).toBeInTheDocument(),
76+
expect(
77+
screen.getByText('Last checked 2 minutes ago'),
78+
).toBeInTheDocument(),
79+
);
80+
});
81+
82+
it('renders the available state when an update is pending', async () => {
83+
invokeMock.mockImplementation(async (cmd: string) => {
84+
if (cmd === 'get_updater_state') {
85+
return {
86+
last_check_at_unix: Math.floor(Date.now() / 1000),
87+
update: { version: '0.9.0', notes_url: null },
88+
settings_snoozed_until: null,
89+
chat_snoozed_until: null,
90+
};
91+
}
92+
return defaultInvoke(cmd);
93+
});
94+
render(<AboutTab {...SAMPLE_PROPS} />);
95+
await waitFor(() =>
96+
expect(screen.getByText('Thuki 0.9.0 is ready')).toBeInTheDocument(),
7497
);
7598
});
7699

77-
it('calls check_for_update when Check now clicked', async () => {
100+
it('calls check_for_update when Check for updates is clicked', async () => {
78101
invokeMock.mockImplementation(async (cmd: string) => defaultInvoke(cmd));
79102
render(<AboutTab {...SAMPLE_PROPS} />);
80-
await waitFor(() => screen.getByRole('button', { name: /check now/i }));
103+
await waitFor(() =>
104+
screen.getByRole('button', { name: /check for updates/i }),
105+
);
81106
await act(async () => {
82-
fireEvent.click(screen.getByRole('button', { name: /check now/i }));
107+
fireEvent.click(
108+
screen.getByRole('button', { name: /check for updates/i }),
109+
);
83110
await Promise.resolve();
84111
});
85112
expect(invokeMock).toHaveBeenCalledWith('check_for_update');
86113
});
87114

115+
it('disables the button while checking and re-enables after the animation hold', async () => {
116+
vi.useFakeTimers({ shouldAdvanceTime: true });
117+
try {
118+
invokeMock.mockImplementation(async (cmd: string) => {
119+
if (cmd === 'check_for_update') {
120+
return {
121+
last_check_at_unix: Math.floor(Date.now() / 1000),
122+
update: null,
123+
settings_snoozed_until: null,
124+
chat_snoozed_until: null,
125+
};
126+
}
127+
return defaultInvoke(cmd);
128+
});
129+
render(<AboutTab {...SAMPLE_PROPS} />);
130+
await waitFor(() =>
131+
screen.getByRole('button', { name: /check for updates/i }),
132+
);
133+
const btn = screen.getByRole('button', { name: /check for updates/i });
134+
await act(async () => {
135+
fireEvent.click(btn);
136+
await Promise.resolve();
137+
});
138+
expect(btn).toHaveAttribute('data-checking', 'true');
139+
expect(btn).toBeDisabled();
140+
141+
// A second click while checking is a no-op.
142+
const callsBefore = invokeMock.mock.calls.filter(
143+
(c: unknown[]) => c[0] === 'check_for_update',
144+
).length;
145+
await act(async () => {
146+
fireEvent.click(btn);
147+
await Promise.resolve();
148+
});
149+
const callsAfter = invokeMock.mock.calls.filter(
150+
(c: unknown[]) => c[0] === 'check_for_update',
151+
).length;
152+
expect(callsAfter).toBe(callsBefore);
153+
154+
// Advance past the animation hold so the timer callback resets state.
155+
await act(async () => {
156+
vi.advanceTimersByTime(1200);
157+
await Promise.resolve();
158+
});
159+
expect(btn).toHaveAttribute('data-checking', 'false');
160+
expect(btn).not.toBeDisabled();
161+
} finally {
162+
vi.useRealTimers();
163+
}
164+
});
165+
166+
it('clears the pending animation timer on unmount', async () => {
167+
vi.useFakeTimers({ shouldAdvanceTime: true });
168+
try {
169+
invokeMock.mockImplementation(async (cmd: string) => {
170+
if (cmd === 'check_for_update') {
171+
return {
172+
last_check_at_unix: Math.floor(Date.now() / 1000),
173+
update: null,
174+
settings_snoozed_until: null,
175+
chat_snoozed_until: null,
176+
};
177+
}
178+
return defaultInvoke(cmd);
179+
});
180+
const { unmount } = render(<AboutTab {...SAMPLE_PROPS} />);
181+
await waitFor(() =>
182+
screen.getByRole('button', { name: /check for updates/i }),
183+
);
184+
await act(async () => {
185+
fireEvent.click(
186+
screen.getByRole('button', { name: /check for updates/i }),
187+
);
188+
await Promise.resolve();
189+
});
190+
// Unmount while the post-check timer is still pending. The cleanup
191+
// effect must clear it; otherwise vitest fake timers would still hold
192+
// a queued callback on unmount.
193+
unmount();
194+
await act(async () => {
195+
vi.advanceTimersByTime(2000);
196+
await Promise.resolve();
197+
});
198+
} finally {
199+
vi.useRealTimers();
200+
}
201+
});
202+
88203
it('renders the Permissions section', async () => {
89204
render(<AboutTab {...SAMPLE_PROPS} />);
90205
await waitFor(() => screen.getByText('Accessibility'));

src/settings/tabs/AboutTab.tsx

Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,28 @@
88
* targeted at the on-disk snapshot rather than a live editor field.
99
*/
1010

11-
import { useEffect, useState } from 'react';
11+
import { useEffect, useRef, useState } from 'react';
1212

1313
import { invoke } from '@tauri-apps/api/core';
1414

1515
import thukiLogo from '../../../src-tauri/icons/128x128.png';
1616
import pkg from '../../../package.json';
1717
import { Section, ConfirmDialog } from '../components';
18+
import { DrawCheckIcon } from '../../components/DrawCheckIcon';
1819
import { Tooltip } from '../../components/Tooltip';
1920
import { useUpdater } from '../../hooks/useUpdater';
2021
import { formatRelative } from '../../utils/relativeTime';
2122
import styles from '../../styles/settings.module.css';
2223
import type { RawAppConfig } from '../types';
2324

25+
/**
26+
* How long the success animation stays visible after `check_for_update`
27+
* resolves. Mirrors the pattern in ModelTab's "Unload now" button: 550 ms
28+
* for the circle draw + 300 ms for the checkmark draw + a small breath so
29+
* the user can register the success before the button reverts.
30+
*/
31+
const CHECK_ANIMATION_HOLD_MS = 1100;
32+
2433
interface AboutTabProps {
2534
onSaved: (next: RawAppConfig) => void;
2635
onReload: () => Promise<void>;
@@ -47,6 +56,36 @@ export function AboutTab({ onSaved, onReload }: AboutTabProps) {
4756
screenRecording: false,
4857
});
4958
const updater = useUpdater();
59+
const [checking, setChecking] = useState(false);
60+
const checkTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
61+
62+
useEffect(() => {
63+
return () => {
64+
if (checkTimerRef.current !== null) {
65+
clearTimeout(checkTimerRef.current);
66+
}
67+
};
68+
}, []);
69+
70+
const handleCheckForUpdates = async () => {
71+
setChecking(true);
72+
try {
73+
await updater.checkNow();
74+
} finally {
75+
// Hold the success animation visible for the full circle + checkmark
76+
// draw before reverting to the idle button. Matches ModelTab's
77+
// "Unload now" pattern.
78+
checkTimerRef.current = setTimeout(() => {
79+
setChecking(false);
80+
checkTimerRef.current = null;
81+
}, CHECK_ANIMATION_HOLD_MS);
82+
}
83+
};
84+
85+
const updateAvailable = updater.state.update !== null;
86+
const lastCheckedLabel = updater.state.last_check_at_unix
87+
? `Last checked ${formatRelative(updater.state.last_check_at_unix)}`
88+
: 'Never checked for updates';
5089

5190
// Refresh permissions on mount and on every window focus.
5291
useEffect(() => {
@@ -146,31 +185,71 @@ export function AboutTab({ onSaved, onReload }: AboutTabProps) {
146185
</div>
147186

148187
<Section heading="Updates">
149-
<div className={styles.row}>
150-
<div className={styles.rowLabelGroup}>
151-
<span className={styles.rowLabel}>Current version</span>
152-
</div>
153-
<div className={styles.rowControl}>
154-
<span>{APP_VERSION}</span>
155-
</div>
156-
</div>
157-
<div className={styles.row}>
158-
<div className={styles.rowLabelGroup}>
159-
<span className={styles.rowLabel}>Last checked</span>
160-
</div>
161-
<div className={styles.rowControl}>
188+
<div
189+
className={styles.updateHero}
190+
data-state={updateAvailable ? 'available' : 'up-to-date'}
191+
>
192+
<div
193+
className={styles.updateHeroStatus}
194+
data-state={updateAvailable ? 'available' : 'up-to-date'}
195+
>
196+
{updateAvailable ? (
197+
<span className={styles.updateHeroPulse} aria-hidden="true" />
198+
) : (
199+
<span className={styles.updateHeroCheckMark} aria-hidden="true">
200+
<svg
201+
viewBox="0 0 16 16"
202+
width="10"
203+
height="10"
204+
fill="none"
205+
aria-hidden="true"
206+
>
207+
<path
208+
d="M3.5 8.5L6.5 11.5L12.5 5"
209+
stroke="currentColor"
210+
strokeWidth="2"
211+
strokeLinecap="round"
212+
strokeLinejoin="round"
213+
/>
214+
</svg>
215+
</span>
216+
)}
162217
<span>
163-
{updater.state.last_check_at_unix
164-
? formatRelative(updater.state.last_check_at_unix)
165-
: 'Never'}
218+
{updateAvailable
219+
? `Thuki ${updater.state.update?.version} is ready`
220+
: 'Thuki is up to date'}
166221
</span>
167222
</div>
223+
<div className={styles.updateHeroMeta}>{lastCheckedLabel}</div>
168224
<button
169225
type="button"
170-
className={styles.checkNowBtn}
171-
onClick={() => void updater.checkNow()}
226+
className={styles.updateHeroBtn}
227+
onClick={() => void handleCheckForUpdates()}
228+
disabled={checking}
229+
data-checking={checking}
230+
aria-label="Check for updates"
172231
>
173-
Check now
232+
{checking ? (
233+
<DrawCheckIcon />
234+
) : (
235+
<svg
236+
viewBox="0 0 24 24"
237+
width="11"
238+
height="11"
239+
fill="none"
240+
stroke="currentColor"
241+
strokeWidth="2.4"
242+
strokeLinecap="round"
243+
strokeLinejoin="round"
244+
aria-hidden="true"
245+
>
246+
<path d="M3 12a9 9 0 0 1 15.5-6.3L21 8" />
247+
<path d="M21 3v5h-5" />
248+
<path d="M21 12a9 9 0 0 1-15.5 6.3L3 16" />
249+
<path d="M3 21v-5h5" />
250+
</svg>
251+
)}
252+
{checking ? 'Checking…' : 'Check for updates'}
174253
</button>
175254
</div>
176255
</Section>

0 commit comments

Comments
 (0)