Skip to content

Commit 90e31dc

Browse files
AkiKurisuclaude
andcommitted
feat(desktop): tint toasts by level and let a notice name its subject
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e2139c8 commit 90e31dc

17 files changed

Lines changed: 469 additions & 287 deletions

File tree

desktop/src/renderer/components/plugins/PluginsView.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { PluginDetailView } from './PluginDetailView'
2424
import { filterVisibleDiagnostics } from './PluginDiagnosticsBanner'
2525
import { PluginManageSurface } from './PluginManageSurface'
2626
import { PLUGIN_CREATOR_SKILL, stagePluginCreationInChat, stagePluginTryInChat } from './pluginDraft'
27+
import { showPluginInstalledToast, showPluginUninstalledToast } from './pluginToasts'
2728
import {
2829
buildCategoryOptions,
2930
buildSections,
@@ -151,7 +152,10 @@ export function PluginsView(): JSX.Element {
151152
setInstallTarget(keepOpenForSetup
152153
? { ...installDialogPlugin, installed: true, enabled: true, installable: false }
153154
: null)
154-
addToast(t('plugins.installSuccess'), 'success')
155+
showPluginInstalledToast(installDialogPlugin, {
156+
message: t('plugins.installSuccess', { name: pluginTitle(installDialogPlugin) }),
157+
...(keepOpenForSetup ? {} : { tryLabel: t('plugins.tryNow') })
158+
})
155159
} catch {
156160
addToast(t('plugins.installFailed'), 'error')
157161
} finally {
@@ -257,7 +261,12 @@ export function PluginsView(): JSX.Element {
257261
// Only now is it known whether the folder carried in-process code, and the trust step
258262
// it owes is the same one a catalog install completes.
259263
if (installed?.dotnet) setInstallTarget(installed)
260-
addToast(t('plugins.installLocal.success'), 'success')
264+
if (installed) {
265+
showPluginInstalledToast(installed, {
266+
message: t('plugins.installLocal.success', { name: pluginTitle(installed) }),
267+
...(installed.dotnet ? {} : { tryLabel: t('plugins.tryNow') })
268+
})
269+
}
261270
} catch (err) {
262271
const detail = err instanceof Error ? extractInstallErrorDetail(err.message) : ''
263272
addToast(detail || t('plugins.installLocal.failed'), 'error')
@@ -302,8 +311,12 @@ export function PluginsView(): JSX.Element {
302311
addToast(t('plugins.installFailed'), 'error')
303312
return
304313
}
305-
addToast(t('plugins.installSuccess'), 'success')
306-
if ((installed.apps ?? []).length > 0 || installed.dotnet != null) setInstallTarget(installed)
314+
const needsSetup = (installed.apps ?? []).length > 0 || installed.dotnet != null
315+
showPluginInstalledToast(installed, {
316+
message: t('plugins.installSuccess', { name: pluginTitle(installed) }),
317+
...(needsSetup ? {} : { tryLabel: t('plugins.tryNow') })
318+
})
319+
if (needsSetup) setInstallTarget(installed)
307320
} catch {
308321
addToast(t('plugins.installFailed'), 'error')
309322
} finally {
@@ -402,7 +415,10 @@ export function PluginsView(): JSX.Element {
402415
if (result.outcome === 'notApplied') {
403416
addToast(operationFailureMessage(result) ?? t('plugins.uninstallFailed'), 'error')
404417
} else {
405-
addToast(t('plugins.uninstallSuccess'), 'success')
418+
showPluginUninstalledToast(
419+
selectedPlugin,
420+
t('plugins.uninstallSuccess', { name: pluginTitle(selectedPlugin) })
421+
)
406422
}
407423
} catch {
408424
addToast(t('plugins.uninstallFailed'), 'error')
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { PluginEntry } from '../../stores/pluginStore'
2+
import { showToast, type ToastLeading } from '../../stores/toastStore'
3+
import { pluginTitle } from './PluginCatalogItem'
4+
import { stagePluginTryInChat } from './pluginDraft'
5+
6+
export function pluginToastLeading(plugin: PluginEntry): ToastLeading {
7+
const src = plugin.interface?.composerIconDataUrl || plugin.interface?.logoDataUrl
8+
return { ...(src ? { src } : {}), fallback: pluginTitle(plugin).slice(0, 1) }
9+
}
10+
11+
export function showPluginInstalledToast(
12+
plugin: PluginEntry,
13+
labels: { message: string; tryLabel?: string }
14+
): void {
15+
showToast({
16+
message: labels.message,
17+
key: `plugin-lifecycle:${plugin.id}`,
18+
leading: pluginToastLeading(plugin),
19+
...(labels.tryLabel ? { action: { label: labels.tryLabel, onClick: () => stagePluginTryInChat(plugin) } } : {})
20+
})
21+
}
22+
23+
export function showPluginUninstalledToast(plugin: PluginEntry, message: string): void {
24+
showToast({ message, type: 'success', key: `plugin-lifecycle:${plugin.id}` })
25+
}

desktop/src/renderer/components/settings/SettingsView.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import { SettingsPanelShell } from './SettingsPanelShell'
6464
import { SettingsBreadcrumb } from './SettingsBreadcrumb'
6565
import { PluginCatalogItem, PluginIcon, pluginSubtitle, pluginTitle } from '../plugins/PluginCatalogItem'
6666
import { PluginInstallDialog } from '../plugins/PluginInstallDialog'
67+
import { showPluginInstalledToast } from '../plugins/pluginToasts'
6768
import {
6869
EditableKeyValueList,
6970
EditableValueList,
@@ -2992,7 +2993,9 @@ export function SettingsView({
29922993
await fetchPlugins()
29932994
await fetchSkills()
29942995
setBrowserUseInstallOpen(false)
2995-
addToast(t('plugins.installSuccess'), 'success')
2996+
showPluginInstalledToast(browserUsePlugin, {
2997+
message: t('plugins.installSuccess', { name: pluginTitle(browserUsePlugin) })
2998+
})
29962999
} catch {
29973000
addToast(t('plugins.installFailed'), 'error')
29983001
} finally {
@@ -3009,7 +3012,9 @@ export function SettingsView({
30093012
await fetchSkills()
30103013
setChromeInstallOpen(false)
30113014
setChromeDetailOpen(true)
3012-
addToast(t('plugins.installSuccess'), 'success')
3015+
showPluginInstalledToast(chromePlugin, {
3016+
message: t('plugins.installSuccess', { name: pluginTitle(chromePlugin) })
3017+
})
30133018
} catch {
30143019
addToast(t('plugins.installFailed'), 'error')
30153020
} finally {
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import {
2+
useCallback,
3+
useEffect,
4+
useLayoutEffect,
5+
useRef,
6+
useState,
7+
type CSSProperties,
8+
type MouseEvent
9+
} from 'react'
10+
import { X, CheckCircle2, Info, TriangleAlert, Undo2, XCircle } from 'lucide-react'
11+
import { useToastStore, type Toast, type ToastType } from '../../stores/toastStore'
12+
import { MarkdownRenderer } from '../conversation/MarkdownRenderer'
13+
import { useT } from '../../contexts/LocaleContext'
14+
import { Button } from './Button'
15+
import { IconButton } from './IconButton'
16+
import { IdentityMark } from './IdentityMark'
17+
18+
/* Matches the opacity transition in primitives/toast.css. */
19+
const LEAVE_MS = 220
20+
21+
export interface ToastCardProps {
22+
toast: Toast
23+
index: number
24+
expanded: boolean
25+
expandedTop: number
26+
collapsedY: number
27+
collapsedScale: number
28+
collapsedOpacity: number
29+
onMeasure: (id: string, height: number) => void
30+
}
31+
32+
export function ToastCard({
33+
toast,
34+
index,
35+
expanded,
36+
expandedTop,
37+
collapsedY,
38+
collapsedScale,
39+
collapsedOpacity,
40+
onMeasure
41+
}: ToastCardProps): JSX.Element {
42+
const cardRef = useRef<HTMLDivElement | null>(null)
43+
const [entered, setEntered] = useState(false)
44+
const [leaving, setLeaving] = useState(false)
45+
const t = useT()
46+
const reduceMotion = reducedMotionActive()
47+
const settleToast = useToastStore((s) => s.settleToast)
48+
const removeToast = useToastStore((s) => s.removeToast)
49+
50+
const remainingRef = useRef(toast.duration)
51+
const startedAtRef = useRef<number | null>(null)
52+
const timerRef = useRef<number | null>(null)
53+
54+
const leave = useCallback(
55+
(via: 'action' | 'expire'): void => {
56+
settleToast(toast.id, via)
57+
if (reduceMotion) {
58+
removeToast(toast.id)
59+
return
60+
}
61+
setLeaving(true)
62+
window.setTimeout(() => removeToast(toast.id), LEAVE_MS)
63+
},
64+
[reduceMotion, removeToast, settleToast, toast.id]
65+
)
66+
67+
useEffect(() => {
68+
if (toast.duration <= 0) return
69+
if (leaving) return
70+
if (expanded) {
71+
if (timerRef.current != null) {
72+
window.clearTimeout(timerRef.current)
73+
timerRef.current = null
74+
}
75+
if (startedAtRef.current != null) {
76+
remainingRef.current = Math.max(
77+
0,
78+
remainingRef.current - (Date.now() - startedAtRef.current)
79+
)
80+
startedAtRef.current = null
81+
}
82+
return
83+
}
84+
startedAtRef.current = Date.now()
85+
timerRef.current = window.setTimeout(() => leave('expire'), remainingRef.current)
86+
return () => {
87+
if (timerRef.current != null) {
88+
window.clearTimeout(timerRef.current)
89+
timerRef.current = null
90+
}
91+
}
92+
}, [expanded, leaving, leave, toast.duration])
93+
94+
useLayoutEffect(() => {
95+
const el = cardRef.current
96+
if (!el) return
97+
onMeasure(toast.id, el.getBoundingClientRect().height)
98+
const ro = new ResizeObserver((entries) => {
99+
for (const entry of entries) onMeasure(toast.id, entry.contentRect.height)
100+
})
101+
ro.observe(el)
102+
return () => ro.disconnect()
103+
}, [onMeasure, toast.id])
104+
105+
useEffect(() => {
106+
if (reduceMotion) {
107+
setEntered(true)
108+
return
109+
}
110+
const id = requestAnimationFrame(() => setEntered(true))
111+
return () => cancelAnimationFrame(id)
112+
}, [reduceMotion])
113+
114+
function handleDismiss(e: MouseEvent<HTMLButtonElement>): void {
115+
e.stopPropagation()
116+
leave('expire')
117+
}
118+
119+
function handleAction(e: MouseEvent<HTMLButtonElement>): void {
120+
e.stopPropagation()
121+
leave('action')
122+
}
123+
124+
const targetY = expanded ? expandedTop : collapsedY
125+
const targetScale = expanded ? 1 : collapsedScale
126+
const targetOpacity = leaving ? 0 : !entered ? 0 : expanded ? 1 : collapsedOpacity
127+
const enterOffset = !entered ? 32 : leaving ? 16 : 0
128+
129+
const cardStyle: CSSProperties = {
130+
transform: `translateY(${targetY}px) translateX(${enterOffset}px) scale(${targetScale})`,
131+
opacity: targetOpacity,
132+
zIndex: 1000 - index
133+
}
134+
135+
const stackedAction = toast.action != null && toast.description != null
136+
// An element that renders nothing still costs Button its icon span and gap.
137+
const actionIcon = toastActionIcon(toast.action?.icon)
138+
const actionButton = toast.action ? (
139+
<Button
140+
variant="secondary"
141+
size="sm"
142+
className="dc-toast__action"
143+
{...(actionIcon ? { iconLeft: actionIcon } : {})}
144+
onClick={handleAction}
145+
>
146+
{toast.action.label}
147+
</Button>
148+
) : null
149+
150+
return (
151+
<div
152+
ref={cardRef}
153+
className="dc-toast"
154+
data-behind={!expanded && index > 0 ? 'true' : undefined}
155+
data-leaving={leaving ? 'true' : undefined}
156+
style={cardStyle}
157+
>
158+
<div className="dc-toast__surface" data-tone={toast.type === 'info' ? undefined : toast.type}>
159+
<div className="dc-toast__head">
160+
<span className="dc-toast__icon" data-mark={toast.leading ? 'true' : undefined} aria-hidden>
161+
{toast.leading ? (
162+
<IdentityMark role="compact" size={20} src={toast.leading.src} fallback={toast.leading.fallback} />
163+
) : (
164+
<ToastIcon type={toast.type} />
165+
)}
166+
</span>
167+
<div className="dc-toast__body">
168+
{toast.markdown ? (
169+
<div className="dc-toast__markdown">
170+
<MarkdownRenderer content={toast.message} />
171+
</div>
172+
) : (
173+
<p className="dc-toast__title">{toast.message}</p>
174+
)}
175+
{toast.description ? (
176+
<p className="dc-toast__description">{toast.description}</p>
177+
) : null}
178+
</div>
179+
{stackedAction ? null : actionButton}
180+
<IconButton
181+
className="dc-toast__close"
182+
icon={<X size={14} aria-hidden />}
183+
label={t('common.close')}
184+
size={24}
185+
radius={8}
186+
onClick={handleDismiss}
187+
/>
188+
</div>
189+
{stackedAction ? <div className="dc-toast__actions">{actionButton}</div> : null}
190+
</div>
191+
</div>
192+
)
193+
}
194+
195+
function ToastIcon({ type }: { type: ToastType }): JSX.Element {
196+
if (type === 'success') return <CheckCircle2 size={16} strokeWidth={2.2} aria-hidden />
197+
if (type === 'warning') return <TriangleAlert size={16} strokeWidth={2.2} aria-hidden />
198+
if (type === 'error') return <XCircle size={16} strokeWidth={2.2} aria-hidden />
199+
return <Info size={16} strokeWidth={2.2} aria-hidden />
200+
}
201+
202+
function toastActionIcon(name?: string): JSX.Element | null {
203+
if (name === 'undo') return <Undo2 size={14} aria-hidden />
204+
return null
205+
}
206+
207+
function reducedMotionActive(): boolean {
208+
if (typeof document === 'undefined') return false
209+
const setting = document.documentElement.dataset.reduceMotion
210+
if (setting === 'on') return true
211+
if (setting === 'off') return false
212+
return typeof window.matchMedia === 'function' &&
213+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
214+
}

0 commit comments

Comments
 (0)