Rebuild Piggy's interface, and give the demo book a business to describe
Piggy answered in raw markdown, threw away every tool result it streamed, and fought the reader's scroll on every token. The three surfaces that made it worth having — what it read, how it reasoned, what it cost — were all on the wire and none of them reached the screen. The transcript is now composed of five parts under components/piggy: answers render through streamdown, the container sticks to the bottom without pinning the reader there, tool steps say what they read and link to the record, and each turn carries its model and token count. Three lifecycle bugs went with them: Stop left a permanent spinner, a truncated stream was indistinguishable from thinking, and a failed send destroyed the message it failed to send. Underneath, the inference path grew timeouts, jittered retries on 429 and 5xx, tolerance of the malformed frames a 30B model emits, and an agent_runs row per turn so chat spend is observable. The system prompt now states that a field ending in Cents is cents — without it nemotron renders costPerGpuHourCents: 189 as "$189 per GPU-hour", which is a 100x error on the most scrutinised number in the room. The demo book was arithmetically incoherent: every deal's value contradicted its own allocation revenue by up to 3.6x, nothing had ever closed, no customer had any paper, and the marketplace was empty. Deal value is now derived from the allocation, the book clears 5.3% across five blocks with one deliberately underwater, and the renewal, compliance and agent-provenance machinery finally has rows to act on. A --clear that deleted every obligation, SLA term and capacity request in the database regardless of origin is scoped to the demo's own ids. Around that: accounts have a detail page, ⌘K searches the book, Settings can mint the API keys it always claimed to, and deploy.sh actually ships the agent instead of silently skipping its compose profile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,14 +3,21 @@
|
||||
*
|
||||
* Stages remain ordered, but wrap into a scanable desktop grid instead of
|
||||
* hiding the back half of the funnel behind a multi-screen horizontal rail.
|
||||
*
|
||||
* Each card can also put itself in front of Piggy. A board of thirteen deals
|
||||
* docked next to an agent that only knows it is "on /demand" answers every
|
||||
* question from stage totals, so the card carries a focus control and the page
|
||||
* publishes that deal as the ambient context while it is held.
|
||||
*/
|
||||
import { useDeferredValue, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { PermissionGrant } from '@pig/core';
|
||||
import { Pencil, Plus, RefreshCw, Search } from 'lucide-react';
|
||||
import { get, money, relativeTime } from '@/lib/api';
|
||||
import { Badge, Button, Card, EmptyState, Input, Skeleton } from '@/components/ui';
|
||||
import { get, money, relativeTime, unitPrice } from '@/lib/api';
|
||||
import { PiggyAskButton } from '@/components/PiggyChat';
|
||||
import { Badge, Button, Card, EmptyState, Input, Skeleton, cn } from '@/components/ui';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { usePiggyContext } from '@/lib/piggy-context';
|
||||
import { can } from '@/lib/permissions';
|
||||
import { DemandDealSheet, SupplyDealSheet, type DemandDealRecord, type SupplyDealRecord } from '@/components/RecordSheets';
|
||||
|
||||
@@ -31,7 +38,7 @@ export function DemandPipeline() {
|
||||
searchText={(deal, accountName) => `${deal.name} ${accountName ?? ''} ${deal.productLine}`}
|
||||
metricLabel="Visible ACV" metricValue={(deals) => money(deals.reduce((total, deal) => total + (deal.acvCents ?? 0), 0))}
|
||||
renderSheet={({ open, onOpenChange, record }) => <DemandDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
|
||||
renderCard={(deal, accountName) => <><p className="truncate font-medium">{deal.name}</p><p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p><div className="mt-2 flex flex-wrap items-center gap-1.5">{deal.acvCents != null ? <span className="nums text-sm font-semibold">{money(deal.acvCents)}</span> : null}<Badge tone="neutral">{deal.productLine.replace(/_/g, ' ')}</Badge>{/* Paper state prevents delivery readiness from being inferred from stage. */}{deal.msaExecuted ? <Badge tone="positive">MSA</Badge> : null}{deal.dpaExecuted ? <Badge tone="positive">DPA</Badge> : null}</div></>}
|
||||
renderCard={(deal) => <div className="mt-2 flex flex-wrap items-center gap-1.5">{deal.acvCents != null ? <span className="nums text-sm font-semibold">{money(deal.acvCents)}</span> : null}<Badge tone="neutral">{deal.productLine.replace(/_/g, ' ')}</Badge>{/* Paper state prevents delivery readiness from being inferred from stage. */}{deal.msaExecuted ? <Badge tone="positive">MSA</Badge> : null}{deal.dpaExecuted ? <Badge tone="positive">DPA</Badge> : null}</div>}
|
||||
/>;
|
||||
}
|
||||
|
||||
@@ -43,11 +50,11 @@ export function SupplyPipeline() {
|
||||
searchText={(deal, accountName) => `${deal.name} ${accountName ?? ''} ${deal.gpuType ?? ''}`}
|
||||
metricLabel="GPU opportunity" metricValue={(deals) => `${deals.reduce((total, deal) => total + (deal.gpuCount ?? 0), 0).toLocaleString()} GPUs`}
|
||||
renderSheet={({ open, onOpenChange, record }) => <SupplyDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
|
||||
renderCard={(deal, accountName) => <><p className="truncate font-medium">{deal.name}</p><p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p><div className="mt-2 flex flex-wrap items-center gap-1.5">{deal.gpuCount != null && deal.gpuType ? <Badge tone="accent">{deal.gpuCount}× {deal.gpuType}</Badge> : null}{deal.targetCostPerGpuHourCents != null ? <span className="nums text-xs text-muted">{money(deal.targetCostPerGpuHourCents)}/hr target</span> : null}</div></>}
|
||||
renderCard={(deal) => <div className="mt-2 flex flex-wrap items-center gap-1.5">{deal.gpuCount != null && deal.gpuType ? <Badge tone="accent">{deal.gpuCount}× {deal.gpuType}</Badge> : null}{/* A per-GPU-hour price goes through `unitPrice`, never `money`: at $1.60 the cents are the number, and `money` drops them when they happen to be round. */}{deal.targetCostPerGpuHourCents != null ? <span className="nums text-xs text-muted">{unitPrice(deal.targetCostPerGpuHourCents)}/GPU-hr target</span> : null}</div>}
|
||||
/>;
|
||||
}
|
||||
|
||||
function PipelineBoard<T extends { id: string; stage: string; updatedAt: string }>({ title, orientation, subtitle, endpoint, team, searchText, metricLabel, metricValue, renderCard, renderSheet }: {
|
||||
function PipelineBoard<T extends { id: string; name: string; stage: string; updatedAt: string }>({ title, orientation, subtitle, endpoint, team, searchText, metricLabel, metricValue, renderCard, renderSheet }: {
|
||||
title: string; orientation: string; subtitle: string; endpoint: string; team: 'supply' | 'demand';
|
||||
searchText: (deal: T, accountName: string | null) => string; metricLabel: string; metricValue: (deals: T[]) => string;
|
||||
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
|
||||
@@ -59,6 +66,7 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
const writable = can(me, 'deal:write', team);
|
||||
const [sheet, setSheet] = useState<{ open: boolean; record?: T }>({ open: false });
|
||||
const [activeStage, setActiveStage] = useState<string | null>(null);
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase());
|
||||
const filteredDeals = useMemo(() => !deferredQuery ? boardQuery.data?.deals ?? [] : (boardQuery.data?.deals ?? []).filter((row) => searchText(row.deal, row.accountName).toLocaleLowerCase().includes(deferredQuery)), [boardQuery.data?.deals, deferredQuery, searchText]);
|
||||
@@ -74,25 +82,99 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
const activeStageCount = stages.filter((stage) => (byStage.get(stage)?.length ?? 0) > 0).length;
|
||||
const sheetNode = renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record });
|
||||
|
||||
if (boardQuery.isLoading) return <div className="space-y-5"><Header title={title} orientation={orientation} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} /><Skeleton className="h-96" /></div>;
|
||||
if (boardQuery.isError) return <div className="space-y-5"><Header title={title} orientation={orientation} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} /><Card><EmptyState title={`${title} pipeline unavailable`} description={boardQuery.error.message} action={<Button variant="outline" onClick={() => void boardQuery.refetch()}><RefreshCw aria-hidden />Try again</Button>} /></Card>{sheetNode}</div>;
|
||||
if (!boardQuery.data || boardQuery.data.deals.length === 0) return <div className="space-y-5"><Header title={title} orientation={orientation} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} /><Card><EmptyState title={`No ${title.toLowerCase()} deals yet`} description="Deals appear here once created. Stages follow how this market actually operates rather than a generic sales funnel." action={<Button variant="primary" disabled={!writable} onClick={() => setSheet({ open: true })}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>} /></Card>{sheetNode}</div>;
|
||||
// What Piggy is looking at while this board is open: the deal the user put in
|
||||
// focus, else the board itself. Resolved against the current rows rather than
|
||||
// stored alongside the id, so a deal that leaves the board on a refetch
|
||||
// returns the dock to the page instead of holding a card nobody can see.
|
||||
const focusedDeal = (boardQuery.data?.deals ?? []).find((row) => row.deal.id === focusedId)?.deal;
|
||||
usePiggyContext(
|
||||
focusedDeal
|
||||
? { type: team === 'demand' ? 'demand_deal' : 'supply_deal', id: focusedDeal.id, label: focusedDeal.name }
|
||||
: { type: 'page', route: team === 'demand' ? '/demand' : '/supply', label: `${title} pipeline` },
|
||||
);
|
||||
|
||||
// Built once: the four returns below all render it, and a header assembled
|
||||
// separately in each is a header that ends up different in the error state.
|
||||
const header = <Header
|
||||
title={title} orientation={orientation} subtitle={subtitle} writable={writable}
|
||||
onCreate={() => setSheet({ open: true })}
|
||||
askLabel={focusedDeal ? 'Ask about this deal' : 'Ask Piggy'}
|
||||
askPrompt={focusedDeal
|
||||
? (team === 'demand'
|
||||
? 'Are the hours behind this deal actually booked, and is it going to close when it says it will?'
|
||||
: 'How many GPU-hours would this add, at what cost per hour, and what is still outstanding before we can sign it?')
|
||||
: (team === 'demand'
|
||||
? 'Which open deal is worth the most, and when is it meant to land?'
|
||||
: 'Are we lining up more capacity than the demand side can absorb?')}
|
||||
/>;
|
||||
|
||||
if (boardQuery.isLoading) return <div className="space-y-5">{header}<Skeleton className="h-96" /></div>;
|
||||
if (boardQuery.isError) return <div className="space-y-5">{header}<Card><EmptyState title={`${title} pipeline unavailable`} description={boardQuery.error.message} action={<Button variant="outline" onClick={() => void boardQuery.refetch()}><RefreshCw aria-hidden />Try again</Button>} /></Card>{sheetNode}</div>;
|
||||
if (!boardQuery.data || boardQuery.data.deals.length === 0) return <div className="space-y-5">{header}<Card><EmptyState title={`No ${title.toLowerCase()} deals yet`} description="Deals appear here once created. Stages follow how this market actually operates rather than a generic sales funnel." action={<Button variant="primary" disabled={!writable} onClick={() => setSheet({ open: true })}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>} /></Card>{sheetNode}</div>;
|
||||
|
||||
const toggleFocus = (id: string) => setFocusedId((current) => (current === id ? null : id));
|
||||
|
||||
return <div className="space-y-5">
|
||||
<Header title={title} orientation={orientation} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} />
|
||||
{header}
|
||||
<section className="grid gap-3 rounded-xl border border-border bg-surface-2/60 p-3 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-center">
|
||||
<label className="relative min-w-0"><span className="sr-only">Search {title.toLowerCase()} pipeline</span><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden /><Input className="h-11 pl-9" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search deal, account or product" /></label>
|
||||
<PipelineStat label={query ? 'Matches' : 'Deals'} value={String(filteredDeals.length)} /><PipelineStat label={metricLabel} value={metricValue(filteredDeals.map((row) => row.deal))} />
|
||||
</section>
|
||||
<div className="lg:hidden"><label className="block text-xs font-medium text-muted" htmlFor={`${team}-stage`}>Focus stage</label><select id={`${team}-stage`} className="mt-1 h-11 w-full rounded-lg border border-border bg-surface px-3 text-sm font-medium text-fg" value={currentStage} onChange={(event) => setActiveStage(event.target.value)}>{stages.map((stage) => <option key={stage} value={stage}>{STAGE_LABELS[stage] ?? stage} · {byStage.get(stage)?.length ?? 0}</option>)}</select><div className="mt-3 space-y-2">{(byStage.get(currentStage) ?? []).map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{(byStage.get(currentStage) ?? []).length === 0 ? <StageEmpty stage={currentStage} filtered={Boolean(deferredQuery)} /> : null}</div></div>
|
||||
<div className="hidden lg:block"><div className="mb-3 flex items-center justify-between gap-3"><p className="text-sm text-muted"><strong className="text-fg">{activeStageCount}</strong> of {stages.length} stages have {deferredQuery ? 'matching' : 'active'} work</p><p className="text-xs text-muted">Stage order runs left to right, then down.</p></div><div className="grid items-start gap-3 lg:grid-cols-3 2xl:grid-cols-4">{stages.map((stage, index) => { const rows = byStage.get(stage) ?? []; return <section key={stage} className="min-w-0 rounded-xl border border-border bg-surface-2/45 p-3" aria-labelledby={`${team}-${stage}`}><div className="mb-3 flex min-h-8 items-center justify-between gap-2"><div className="flex min-w-0 items-center gap-2"><span className="nums flex size-6 shrink-0 items-center justify-center rounded-full bg-surface text-[11px] text-muted">{index + 1}</span><h2 id={`${team}-${stage}`} className="truncate text-sm font-semibold">{STAGE_LABELS[stage] ?? stage}</h2></div><Badge tone={rows.length ? 'accent' : 'neutral'}>{rows.length}</Badge></div><div className="space-y-2">{rows.map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{rows.length === 0 ? <StageEmpty stage={stage} filtered={Boolean(deferredQuery)} compact /> : null}</div></section>; })}</div></div>
|
||||
<div className="lg:hidden"><label className="block text-xs font-medium text-muted" htmlFor={`${team}-stage`}>Focus stage</label><select id={`${team}-stage`} className="mt-1 h-11 w-full rounded-lg border border-border bg-surface px-3 text-sm font-medium text-fg" value={currentStage} onChange={(event) => setActiveStage(event.target.value)}>{stages.map((stage) => <option key={stage} value={stage}>{STAGE_LABELS[stage] ?? stage} · {byStage.get(stage)?.length ?? 0}</option>)}</select><div className="mt-3 space-y-2">{(byStage.get(currentStage) ?? []).map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} focused={row.deal.id === focusedId} onFocus={() => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{(byStage.get(currentStage) ?? []).length === 0 ? <StageEmpty stage={currentStage} filtered={Boolean(deferredQuery)} /> : null}</div></div>
|
||||
<div className="hidden lg:block"><div className="mb-3 flex items-center justify-between gap-3"><p className="text-sm text-muted"><strong className="text-fg">{activeStageCount}</strong> of {stages.length} stages have {deferredQuery ? 'matching' : 'active'} work</p><p className="text-xs text-muted">Stage order runs left to right, then down.</p></div><div className="grid items-start gap-3 lg:grid-cols-3 2xl:grid-cols-4">{stages.map((stage, index) => { const rows = byStage.get(stage) ?? []; return <section key={stage} className="min-w-0 rounded-xl border border-border bg-surface-2/45 p-3" aria-labelledby={`${team}-${stage}`}><div className="mb-3 flex min-h-8 items-center justify-between gap-2"><div className="flex min-w-0 items-center gap-2"><span className="nums flex size-6 shrink-0 items-center justify-center rounded-full bg-surface text-[11px] text-muted">{index + 1}</span><h2 id={`${team}-${stage}`} className="truncate text-sm font-semibold">{STAGE_LABELS[stage] ?? stage}</h2></div><Badge tone={rows.length ? 'accent' : 'neutral'}>{rows.length}</Badge></div><div className="space-y-2">{rows.map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} focused={row.deal.id === focusedId} onFocus={() => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{rows.length === 0 ? <StageEmpty stage={stage} filtered={Boolean(deferredQuery)} compact /> : null}</div></section>; })}</div></div>
|
||||
{sheetNode}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function DealCard<T extends { id: string; updatedAt: string }>({ row, renderCard, writable, onEdit }: { row: { deal: T; accountName: string | null }; renderCard: (deal: T, accountName: string | null) => React.ReactNode; writable: boolean; onEdit(): void }) {
|
||||
return <article className="card relative min-w-0 p-3 pr-12 shadow-sm"><Button size="icon" variant="ghost" className="absolute right-1 top-1" disabled={!writable} onClick={onEdit} title={writable ? 'Edit deal' : 'Deal write access required'}><Pencil aria-hidden /><span className="sr-only">Edit deal</span></Button>{renderCard(row.deal, row.accountName)}<p className="mt-2 text-[11px] text-muted">Updated {relativeTime(row.deal.updatedAt)}</p></article>;
|
||||
/**
|
||||
* One deal on the board.
|
||||
*
|
||||
* The name and account are drawn here rather than by `renderCard` because they
|
||||
* are the control that points Piggy at this deal, and a second icon button
|
||||
* beside the pencil would have cost the title another 44px of a card that is
|
||||
* already a quarter of a column wide — the board would have been asking which
|
||||
* matters more, reading the deal or asking about it.
|
||||
*/
|
||||
function DealCard<T extends { id: string; name: string; updatedAt: string }>({ row, renderCard, writable, focused, onEdit, onFocus }: { row: { deal: T; accountName: string | null }; renderCard: (deal: T, accountName: string | null) => React.ReactNode; writable: boolean; focused: boolean; onEdit(): void; onFocus(): void }) {
|
||||
// `ring-brand`, not `ring-accent`: in this Tailwind config `accent` is
|
||||
// shadcn's subtle surface, so a ring drawn in it is invisible against the
|
||||
// card. The brand is the monochrome that inverts with the theme.
|
||||
return <article className={cn('card relative min-w-0 p-3 pr-12 shadow-sm', focused && 'ring-2 ring-brand')}>
|
||||
<Button size="icon" variant="ghost" className="absolute right-1 top-1" disabled={!writable} onClick={onEdit} title={writable ? 'Edit deal' : 'Deal write access required'}><Pencil aria-hidden /><span className="sr-only">Edit deal</span></Button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={focused}
|
||||
onClick={onFocus}
|
||||
title={focused ? 'Stop pointing Piggy at this deal' : 'Point Piggy at this deal'}
|
||||
className="tap -mx-2 -mt-1 block w-[calc(100%+1rem)] rounded-lg px-2 py-1 text-left transition-colors hover:bg-surface-2"
|
||||
>
|
||||
<span className="block truncate font-medium">{row.deal.name}</span>
|
||||
<span className="block truncate text-xs text-muted">{row.accountName ?? 'No account'}</span>
|
||||
<span className="sr-only">{focused ? 'Piggy is looking at this deal' : 'Point Piggy at this deal'}</span>
|
||||
</button>
|
||||
{renderCard(row.deal, row.accountName)}
|
||||
<p className="mt-2 text-[11px] text-muted">Updated {relativeTime(row.deal.updatedAt)}</p>
|
||||
</article>;
|
||||
}
|
||||
function PipelineStat({ label, value }: { label: string; value: string }) { return <div className="min-w-[7rem] rounded-lg bg-surface px-3 py-2"><p className="text-[11px] font-medium uppercase tracking-wide text-muted">{label}</p><p className="nums mt-0.5 truncate text-sm font-semibold">{value}</p></div>; }
|
||||
function StageEmpty({ stage, filtered, compact = false }: { stage: string; filtered: boolean; compact?: boolean }) { return <p className={compact ? 'rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-muted' : 'py-10 text-center text-sm text-muted'}>{filtered ? 'No matching deals' : `Nothing in ${STAGE_LABELS[stage] ?? stage}`}</p>; }
|
||||
function Header({ title, orientation, subtitle, writable, onCreate }: { title: string; orientation: string; subtitle: string; writable: boolean; onCreate(): void }) { return <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"><div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1><Badge tone={title === 'Supply' ? 'info' : 'neutral'}>{orientation}</Badge></div><p className="mt-1 max-w-2xl text-sm text-muted">{subtitle}</p></div><Button className="min-h-11 sm:shrink-0" variant="primary" disabled={!writable} onClick={onCreate} title={writable ? undefined : 'Deal write access required'}><Plus aria-hidden />New {title.toLowerCase()} deal</Button></header>; }
|
||||
function Header({ title, orientation, subtitle, writable, onCreate, askLabel, askPrompt }: { title: string; orientation: string; subtitle: string; writable: boolean; onCreate(): void; askLabel: string; askPrompt: string }) {
|
||||
return <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1><Badge tone={title === 'Supply' ? 'info' : 'neutral'}>{orientation}</Badge></div><p className="mt-1 max-w-2xl text-sm text-muted">{subtitle}</p></div>
|
||||
{/*
|
||||
Reversed above `sm` rather than reordered: stacked on a phone the primary
|
||||
action has to come first, and on a wide header the same button belongs at
|
||||
the right edge where it has always been.
|
||||
*/}
|
||||
<div className="flex flex-col gap-2 sm:shrink-0 sm:flex-row-reverse">
|
||||
<Button className="min-h-11" variant="primary" disabled={!writable} onClick={onCreate} title={writable ? undefined : 'Deal write access required'}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>
|
||||
{/*
|
||||
No `context` prop on purpose: this asks about whatever the board has
|
||||
published, which is the focused deal when there is one. Passing the page
|
||||
here would pin it to the board and quietly ignore the card the user just
|
||||
put in focus.
|
||||
*/}
|
||||
<PiggyAskButton label={askLabel} prompt={askPrompt} />
|
||||
</div>
|
||||
</header>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user