release: 0.1.10 — balance widget actually shows + avatar ring
Balance pill was rendering nothing for admin/root accounts because mcp-server's §4 returns HEICODE_USER_NOT_FOUND for users that never came through from-agnet onboarding. Fall back to Heicode NewAPI's own /api/user/self when that happens; reshape into the same envelope so the UI is path-agnostic. New balanceStore (zustand) — single polling loop, BalanceBar + avatar ring share it. AppShell starts it once auth bootstraps. BalanceBar now shows a "loading…" placeholder on first fetch so the widget is visible from frame one. SidebarUserCard avatar wears an SVG ring whose arc length tracks remaining/(remaining+used) and color hits the same green→amber→red thresholds as the bar. Manager: /desktop-client drops the manifest-notes wall of text, the old HEICODE_DESKTOP_FILE_* subtitle goes away, and a Mac fallback entry is always spliced in when the live manifest is Win-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "heicode-desktop",
|
||||
"private": true,
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.10",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "heicode-desktop"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json",
|
||||
"productName": "HeiCode",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.10",
|
||||
"identifier": "com.heicode.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { heicodeAuthApi, type HeicodeBalance } from '../../api/heicodeAuth'
|
||||
import { useBalanceStore } from '../../stores/balanceStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
// Quota unit convention (NewAPI / Heicode): 500,000 units = USD $1. The
|
||||
@@ -7,23 +6,12 @@ import { useTranslation } from '../../i18n'
|
||||
// just hard-code the constant — display is informational, not used for
|
||||
// charging math.
|
||||
const QUOTA_PER_USD = 500_000
|
||||
const POLL_MS = 60_000
|
||||
|
||||
function formatUsd(quota: number): string {
|
||||
const usd = quota / QUOTA_PER_USD
|
||||
return `$${usd >= 100 ? usd.toFixed(2) : usd >= 1 ? usd.toFixed(2) : usd.toFixed(4)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Bar fill semantics: `remaining / (remaining + used)` — how much of the
|
||||
* lifetime-funded total is still in the wallet. 100% = brand-new account
|
||||
* that hasn't spent anything; 0% = quota fully consumed.
|
||||
*
|
||||
* Color thresholds:
|
||||
* ≥ 30% → green (healthy)
|
||||
* ≥ 10% → amber (getting low)
|
||||
* < 10% → red (top up soon)
|
||||
*/
|
||||
function healthOf(remainingFraction: number): { color: string; bg: string } {
|
||||
if (remainingFraction >= 0.3) {
|
||||
return { color: 'var(--color-success)', bg: 'rgba(34,197,94,0.16)' }
|
||||
@@ -36,29 +24,28 @@ function healthOf(remainingFraction: number): { color: string; bg: string } {
|
||||
|
||||
/**
|
||||
* Compact balance pill with progress bar that lives right under the chat
|
||||
* composer. Renders nothing until we have a positive answer — silent on
|
||||
* logged-out / failed states so the layout doesn't flash an error strip.
|
||||
* composer. Reads from the shared balanceStore so the user-card avatar
|
||||
* ring stays in sync without a second fetch.
|
||||
*/
|
||||
export function BalanceBar() {
|
||||
const [balance, setBalance] = useState<HeicodeBalance | null>(null)
|
||||
const balance = useBalanceStore((s) => s.balance)
|
||||
const initializing = useBalanceStore((s) => s.initializing)
|
||||
const t = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
const tick = async () => {
|
||||
const b = await heicodeAuthApi.balance()
|
||||
if (!cancelled) setBalance(b)
|
||||
if (!balance) {
|
||||
// Hide entirely after initial load fails so we don't squat layout
|
||||
// for a permanently-broken endpoint. Show a muted placeholder only
|
||||
// during the first fetch.
|
||||
if (!initializing) return null
|
||||
return (
|
||||
<div className="mx-auto mb-2 mt-1 w-fit rounded-[14px] border border-[var(--color-border)]/30 bg-[var(--color-surface-container-low)]/40 px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[14px]">account_balance_wallet</span>
|
||||
<span>{t('balance.loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
void tick()
|
||||
timer = setInterval(tick, POLL_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timer) clearInterval(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!balance) return null
|
||||
|
||||
const total = balance.quota + balance.usedQuota
|
||||
const remainingFraction = total > 0 ? balance.quota / total : 1
|
||||
@@ -88,9 +75,6 @@ export function BalanceBar() {
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Progress bar — fills with remaining / total. The full track is the
|
||||
lifetime-funded amount; the filled portion is what's still in the
|
||||
wallet. Color tracks the same green/amber/red health bands. */}
|
||||
<div
|
||||
className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full"
|
||||
style={{ backgroundColor: health.bg }}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { HeicodeLoginPage } from '../login/HeicodeLoginPage'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useUIStore, type SettingsTab } from '../../stores/uiStore'
|
||||
import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore'
|
||||
import { useBalanceStore } from '../../stores/balanceStore'
|
||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
|
||||
import { initializeDesktopServerUrl } from '../../lib/desktopRuntime'
|
||||
import { TabBar } from './TabBar'
|
||||
@@ -62,6 +63,17 @@ export function AppShell() {
|
||||
}
|
||||
}, [fetchSettings, fetchAuth])
|
||||
|
||||
// Start the shared balance poller once the auth bootstrap is done so
|
||||
// BalanceBar + the avatar ring read from the same in-memory source.
|
||||
// Idempotent on the store side; stops when AppShell unmounts (rare).
|
||||
useEffect(() => {
|
||||
if (!authHasFetched) return
|
||||
if (!authStatus?.loggedIn) return
|
||||
const store = useBalanceStore.getState()
|
||||
store.start()
|
||||
return () => store.stop()
|
||||
}, [authHasFetched, authStatus?.loggedIn])
|
||||
|
||||
// Listen for macOS native menu navigation events (About / Settings)
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../stores/tabStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore'
|
||||
import { useBalanceRemainingFraction } from '../../stores/balanceStore'
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
||||
const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform)
|
||||
@@ -619,6 +620,55 @@ function openManagerProfile() {
|
||||
.catch(() => window.open(HEICODE_MANAGER_URL, '_blank'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny SVG ring around the avatar showing balance health. The arc length
|
||||
* equals `remainingFraction`; color mirrors BalanceBar's traffic-light
|
||||
* thresholds (green ≥30%, amber ≥10%, red <10%). When balance is null
|
||||
* we skip the ring entirely so a logged-out / pre-fetch state doesn't
|
||||
* paint a stale color.
|
||||
*/
|
||||
function BalanceRing({ size, remainingFraction }: { size: number; remainingFraction: number | null }) {
|
||||
if (remainingFraction === null) return null
|
||||
const stroke = 2
|
||||
const r = (size - stroke) / 2
|
||||
const circumference = 2 * Math.PI * r
|
||||
const clamped = Math.max(0, Math.min(1, remainingFraction))
|
||||
const color =
|
||||
clamped >= 0.3 ? 'var(--color-success)' :
|
||||
clamped >= 0.1 ? 'var(--color-warning)' :
|
||||
'var(--color-error)'
|
||||
return (
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
aria-hidden
|
||||
>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeOpacity={0.18}
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${circumference * clamped} ${circumference}`}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarUserCard({
|
||||
user,
|
||||
collapsed,
|
||||
@@ -637,17 +687,19 @@ function SidebarUserCard({
|
||||
const roleBadge = role && role !== 'user' && role !== '1' ? role : null
|
||||
const t = useTranslation()
|
||||
const profileTitle = t('sidebar.userCard.openProfile')
|
||||
const balanceRemainingFraction = useBalanceRemainingFraction()
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openManagerProfile}
|
||||
className="mb-2 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full text-[12px] font-bold text-white transition-opacity hover:opacity-85"
|
||||
className="relative mb-2 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full text-[12px] font-bold text-white transition-opacity hover:opacity-85"
|
||||
style={{ backgroundImage: 'var(--gradient-brand-wordmark)' }}
|
||||
title={`${profileTitle}\n${user.email || name}`}
|
||||
>
|
||||
{initial}
|
||||
<BalanceRing size={36} remainingFraction={balanceRemainingFraction} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -664,10 +716,11 @@ function SidebarUserCard({
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
|
||||
className="relative flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
|
||||
style={{ backgroundImage: 'var(--gradient-brand-wordmark)' }}
|
||||
>
|
||||
{initial}
|
||||
<BalanceRing size={32} remainingFraction={balanceRemainingFraction} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 leading-tight">
|
||||
<div className="truncate text-[12px] font-semibold text-[var(--color-text-primary)]">
|
||||
|
||||
@@ -42,6 +42,7 @@ export const en = {
|
||||
'balance.usedPrefix': 'used ',
|
||||
'balance.requestsSuffix': 'reqs',
|
||||
'balance.tooltip': 'Balance from Heicode platform · bar shows remaining / total',
|
||||
'balance.loading': 'Loading balance…',
|
||||
'sidebar.collapse': 'Collapse sidebar',
|
||||
'sidebar.expand': 'Expand sidebar',
|
||||
'sidebar.logout': 'Log out',
|
||||
|
||||
@@ -44,6 +44,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'balance.usedPrefix': '已用 ',
|
||||
'balance.requestsSuffix': '次',
|
||||
'balance.tooltip': '余额来自 Heicode 平台 · 进度条为剩余 / 总额',
|
||||
'balance.loading': '余额加载中…',
|
||||
'sidebar.collapse': '折叠侧边栏',
|
||||
'sidebar.expand': '展开侧边栏',
|
||||
'sidebar.logout': '退出登录',
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// desktop/src/stores/balanceStore.ts
|
||||
//
|
||||
// Single source of truth for the Heicode balance pill + user-card ring.
|
||||
// One polling loop, multiple subscribers — avoids stampeding the
|
||||
// `/api/heicode-auth/balance` endpoint when both surfaces are visible.
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { heicodeAuthApi, type HeicodeBalance } from '../api/heicodeAuth'
|
||||
import { isTauriRuntime } from '../lib/desktopRuntime'
|
||||
|
||||
const POLL_MS = 60_000
|
||||
|
||||
type BalanceState = {
|
||||
balance: HeicodeBalance | null
|
||||
/** True while the very first fetch is still in flight; UI uses this
|
||||
* to show a "loading" placeholder instead of an absent widget. */
|
||||
initializing: boolean
|
||||
|
||||
/** Start the polling loop. Idempotent — subsequent calls no-op.
|
||||
* Mount this once from AppShell. */
|
||||
start: () => void
|
||||
/** Tear-down for tests / logout flows. */
|
||||
stop: () => void
|
||||
/** Manual refresh without waiting for the next tick. */
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
export const useBalanceStore = create<BalanceState>((set) => ({
|
||||
balance: null,
|
||||
initializing: true,
|
||||
|
||||
start: () => {
|
||||
if (timer) return
|
||||
const tick = async () => {
|
||||
const b = await heicodeAuthApi.balance()
|
||||
set({ balance: b, initializing: false })
|
||||
}
|
||||
// Skip the network call entirely when we're in the dev / browser
|
||||
// preview without the tauri host. Most callers won't be logged in
|
||||
// there anyway, and the fetch surfaces are noisy when it 401s.
|
||||
if (!isTauriRuntime()) {
|
||||
set({ initializing: false })
|
||||
return
|
||||
}
|
||||
void tick()
|
||||
timer = setInterval(tick, POLL_MS)
|
||||
},
|
||||
|
||||
stop: () => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
set({ balance: null, initializing: true })
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
if (!isTauriRuntime()) return
|
||||
const b = await heicodeAuthApi.balance()
|
||||
set({ balance: b, initializing: false })
|
||||
},
|
||||
}))
|
||||
|
||||
/** Convenience selector — `null` while loading or when balance is
|
||||
* genuinely unavailable; the BalanceRing component treats both as
|
||||
* "don't draw an arc". */
|
||||
export function useBalanceRemainingFraction(): number | null {
|
||||
const balance = useBalanceStore((s) => s.balance)
|
||||
if (!balance) return null
|
||||
const total = balance.quota + balance.usedQuota
|
||||
if (total <= 0) return 1
|
||||
return Math.max(0, Math.min(1, balance.quota / total))
|
||||
}
|
||||
@@ -142,10 +142,13 @@ export async function handleHeicodeAuthApi(
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
// GET /api/heicode-auth/balance — proxies mcp-server §4 to surface
|
||||
// the active user's remaining quota / usage in the desktop UI. Reads
|
||||
// the access token from the active provider's mcpAuth record (same
|
||||
// source the in-app TitleBar uses for the user pill).
|
||||
// GET /api/heicode-auth/balance — try mcp-server §4 first, fall
|
||||
// back to hitting the Heicode NewAPI's own /api/user/self with the
|
||||
// provider's API key when mcp-server reports the user as unmirrored
|
||||
// (HEICODE_USER_NOT_FOUND — happens for admin/root accounts that
|
||||
// never came through the from-agnet onboarding flow). The fallback
|
||||
// shape is rewritten to the same HeicodeBalance envelope so the
|
||||
// UI doesn't care which path served it.
|
||||
if (action === 'balance' && req.method === 'GET') {
|
||||
const { providers, activeId } = await providerService.listProviders()
|
||||
const active = activeId ? providers.find((p) => p.id === activeId) : null
|
||||
@@ -155,11 +158,66 @@ export async function handleHeicodeAuthApi(
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
const base = active.mcpAuth.managerLoginUrl.replace(/\/+$/, '')
|
||||
const upstream = await fetch(`${base}/api/user/heicode/balance`, {
|
||||
const mcpBase = active.mcpAuth.managerLoginUrl.replace(/\/+$/, '')
|
||||
const upstream = await fetch(`${mcpBase}/api/user/heicode/balance`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${active.mcpAuth.accessToken}` },
|
||||
})
|
||||
// Happy path — mcp-server knows the user.
|
||||
if (upstream.ok) {
|
||||
const body = await upstream.text()
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' },
|
||||
})
|
||||
}
|
||||
// Fallback: hit Heicode NewAPI directly. Only applies if the
|
||||
// provider stores an API key (Heicode preset has needsApiKey=true).
|
||||
const heicodeBase = active.baseUrl?.replace(/\/+$/, '') ?? ''
|
||||
if (active.apiKey && heicodeBase) {
|
||||
try {
|
||||
const self = await fetch(`${heicodeBase}/api/user/self`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${active.apiKey}` },
|
||||
})
|
||||
if (self.ok) {
|
||||
const json = await self.json() as {
|
||||
data?: {
|
||||
id?: number
|
||||
username?: string
|
||||
email?: string
|
||||
display_name?: string
|
||||
group?: string
|
||||
status?: number
|
||||
quota?: number
|
||||
used_quota?: number
|
||||
request_count?: number
|
||||
}
|
||||
}
|
||||
const u = json.data ?? {}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
heicodeUserId: u.id ?? 0,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
displayName: u.display_name,
|
||||
group: u.group,
|
||||
status: u.status,
|
||||
quota: u.quota ?? 0,
|
||||
usedQuota: u.used_quota ?? 0,
|
||||
requestCount: u.request_count,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// fall through to forward mcp-server's original error
|
||||
}
|
||||
}
|
||||
// Neither path worked — return whatever mcp-server said.
|
||||
const body = await upstream.text()
|
||||
return new Response(body, {
|
||||
status: upstream.status,
|
||||
|
||||
@@ -124,6 +124,19 @@ func filenameFromURL(rawURL string) string {
|
||||
return rawURL
|
||||
}
|
||||
|
||||
// Fallback URLs for platforms the live manifest doesn't currently carry.
|
||||
// We ship Win and Mac on different cadences (Mac builds depend on physical
|
||||
// hardware access). When a release goes out Win-only, the manifest only
|
||||
// names windows-x86_64 — but the Manager download page should still show
|
||||
// the latest Mac binary that ever shipped to blob, so Mac users aren't
|
||||
// stranded.
|
||||
//
|
||||
// Update these constants whenever a NEW Mac binary is uploaded to blob.
|
||||
const (
|
||||
fallbackMacArmVersion = "0.1.5"
|
||||
fallbackMacArmURL = "https://heicodeblob.blob.core.windows.net/msi/desktop/0.1.5/HeiCode.app.tar.gz"
|
||||
)
|
||||
|
||||
// GetDesktopDownloads returns the latest released desktop client metadata
|
||||
// (per platform) for authenticated users.
|
||||
func GetDesktopDownloads(c *gin.Context) {
|
||||
@@ -136,7 +149,7 @@ func GetDesktopDownloads(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]desktopDownloadItem, 0, len(manifest.Platforms))
|
||||
items := make([]desktopDownloadItem, 0, 3)
|
||||
add := func(id, label, key string) {
|
||||
p, ok := manifest.Platforms[key]
|
||||
if !ok || strings.TrimSpace(p.URL) == "" {
|
||||
@@ -153,12 +166,26 @@ func GetDesktopDownloads(c *gin.Context) {
|
||||
add("macos_arm64", "macOS (Apple silicon)", "darwin-aarch64")
|
||||
add("macos_x64", "macOS (Intel)", "darwin-x86_64")
|
||||
|
||||
// If the live manifest doesn't carry a Mac arm64 entry (Win-only
|
||||
// release), splice in the last known Mac binary so the download
|
||||
// page never goes blank on Mac users.
|
||||
if _, hasMac := manifest.Platforms["darwin-aarch64"]; !hasMac {
|
||||
items = append(items, desktopDownloadItem{
|
||||
ID: "macos_arm64",
|
||||
Label: "macOS (Apple silicon, " + fallbackMacArmVersion + ")",
|
||||
Filename: filenameFromURL(fallbackMacArmURL),
|
||||
DownloadURL: fallbackMacArmURL,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
// `Notes` deliberately dropped here — the download page is for the
|
||||
// download links + version number only; users don't want a wall of
|
||||
// release-note text below it.
|
||||
"data": desktopDownloadsPayload{
|
||||
Version: manifest.Version,
|
||||
Notes: manifest.Notes,
|
||||
Items: items,
|
||||
},
|
||||
})
|
||||
|
||||
+1
-6
@@ -41,7 +41,7 @@ export function DesktopClientDownloadPage() {
|
||||
</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Description>
|
||||
{t(
|
||||
'Installers are served only after you sign in. Place files on the server and set HEICODE_DESKTOP_FILE_* environment variables.',
|
||||
'Sign in required to access the installers.',
|
||||
)}
|
||||
</SectionPageLayout.Description>
|
||||
<SectionPageLayout.Content>
|
||||
@@ -58,11 +58,6 @@ export function DesktopClientDownloadPage() {
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('Version: {{version}}', { version: payload.version })}
|
||||
</p>
|
||||
{payload.notes ? (
|
||||
<p className='text-muted-foreground whitespace-pre-wrap text-sm'>
|
||||
{payload.notes}
|
||||
</p>
|
||||
) : null}
|
||||
<ul className='flex flex-col gap-3'>
|
||||
{payload.items.map((item) => (
|
||||
<li
|
||||
|
||||
Reference in New Issue
Block a user