mirror of
https://github.com/block/buzz.git
synced 2026-11-05 08:51:10 +00:00
feat(market): make market channels read as channels
Market channels regain their product identity and wallet rail: - MarketChannelHome docks AgentWalletPanel to the right of the live channel with a header toggle, mirroring the project context rail. The wallet now derives from signed channel events: escrowed fake sats (awarded, unsettled) vs released fake sats (settled), with the contract event id as the escrow account. - ChannelScreenHeader swaps in the listing title, summary, and lifecycle status badge when a market projection is in context, so the channel header names the product rather than the channel slug. - Inside market channels, lifecycle envelopes render as conversation messages: the agent's message text as the body with a compact inline badge naming the signed transition and amount. Rejected envelopes get an inline chip with the projector's reason. Pulse keeps the full MarketEventCard. Typecheck clean; full desktop suite 5,834 pass. Signed-off-by: Moneypennie <62d0caca1ee9c3ce3a753262374b3b794f0e1196cfa8b1294b0affbc90130307@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { LogIn, SquareTerminal } from "lucide-react";
|
||||
import { LogIn, SquareTerminal, Store } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||
@@ -9,6 +9,7 @@ import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDi
|
||||
import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph";
|
||||
import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge";
|
||||
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
|
||||
import { useMarketChannel } from "@/features/market/lib/MarketChannelContext";
|
||||
import {
|
||||
DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
|
||||
ProfileAvatarWithStatus,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
} from "@/features/profile/ui/ProfileAvatarWithStatus";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import type { Channel, PresenceStatus } from "@/shared/api/types";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import {
|
||||
@@ -70,6 +72,7 @@ export function ChannelScreenHeader({
|
||||
onManageChannel,
|
||||
onToggleMembers,
|
||||
}: ChannelScreenHeaderProps) {
|
||||
const marketProjection = useMarketChannel();
|
||||
const isGroupDm =
|
||||
activeChannel?.channelType === "dm" &&
|
||||
activeDmHeaderParticipants.length > 1;
|
||||
@@ -143,9 +146,15 @@ export function ChannelScreenHeader({
|
||||
chromeWrapperRef={chromeWrapperRef}
|
||||
actions={actions}
|
||||
channelType={activeChannel?.channelType}
|
||||
description={getChannelDescription(activeChannel)}
|
||||
description={
|
||||
marketProjection
|
||||
? marketProjection.scenario.summary
|
||||
: getChannelDescription(activeChannel)
|
||||
}
|
||||
leadingContent={
|
||||
activeChannel?.channelType === "dm" ? (
|
||||
marketProjection ? (
|
||||
<Store className="h-4 w-4 translate-y-px text-muted-foreground" />
|
||||
) : activeChannel?.channelType === "dm" ? (
|
||||
isGroupDm ? (
|
||||
<DmHeaderParticipantStack
|
||||
participants={activeDmHeaderParticipants}
|
||||
@@ -194,11 +203,17 @@ export function ChannelScreenHeader({
|
||||
) : undefined
|
||||
}
|
||||
statusBadge={
|
||||
<ChannelHeaderStatusBadge
|
||||
ephemeralDisplay={activeChannelEphemeralDisplay}
|
||||
/>
|
||||
marketProjection ? (
|
||||
<Badge data-testid="market-header-status" variant="outline">
|
||||
{marketProjection.scenario.status}
|
||||
</Badge>
|
||||
) : (
|
||||
<ChannelHeaderStatusBadge
|
||||
ephemeralDisplay={activeChannelEphemeralDisplay}
|
||||
/>
|
||||
)
|
||||
}
|
||||
title={activeChannelTitle}
|
||||
title={marketProjection?.scenario.title ?? activeChannelTitle}
|
||||
transparentChrome={transparentChrome}
|
||||
visibility={activeChannel?.visibility}
|
||||
/>
|
||||
|
||||
@@ -148,6 +148,7 @@ test("projectMarketChannel folds a channel contract through fake settlement", ()
|
||||
"Sandbox settlement complete",
|
||||
);
|
||||
assert.deepEqual(projection.rejected, []);
|
||||
assert.deepEqual(projection.wallet, { escrowedSats: 0, settledSats: 50 });
|
||||
});
|
||||
|
||||
test("channel tags and the first top-level contract define the canonical market", () => {
|
||||
@@ -263,6 +264,40 @@ test("reverse auction enforces the minimum decrement", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("an awarded but unsettled market escrows the award total", () => {
|
||||
const projection = projectMarketChannel(
|
||||
[
|
||||
note(LISTING_ID, SELLER, 100, contract({ quantity: 2 })),
|
||||
note(
|
||||
RESPONSE_ID,
|
||||
BUYER,
|
||||
101,
|
||||
lifecycle("response", {
|
||||
actorName: "Buyer Agent",
|
||||
quantity: 2,
|
||||
amountSats: 50,
|
||||
message: "I reserve two reports.",
|
||||
}),
|
||||
),
|
||||
note(
|
||||
AWARD_ID,
|
||||
SELLER,
|
||||
102,
|
||||
lifecycle("award", {
|
||||
responseEventId: RESPONSE_ID,
|
||||
actorName: "Seller Agent",
|
||||
quantity: 2,
|
||||
amountSats: 50,
|
||||
}),
|
||||
),
|
||||
],
|
||||
CHANNEL_ID,
|
||||
);
|
||||
|
||||
assert.ok(projection);
|
||||
assert.deepEqual(projection.wallet, { escrowedSats: 100, settledSats: 0 });
|
||||
});
|
||||
|
||||
test("a lifecycle envelope cannot be accepted twice under one event id", () => {
|
||||
const sharedEventId = RESPONSE_ID;
|
||||
const projection = projectMarketChannel(
|
||||
|
||||
@@ -102,6 +102,14 @@ export type MarketProjection = {
|
||||
listingEventId: string;
|
||||
scenario: MarketScenario;
|
||||
rejected: Array<{ eventId: string; reason: string }>;
|
||||
wallet: MarketWallet;
|
||||
};
|
||||
|
||||
export type MarketWallet = {
|
||||
/** Awarded but not yet settled, in fake sats. */
|
||||
escrowedSats: number;
|
||||
/** Total released by signed settlements, in fake sats. */
|
||||
settledSats: number;
|
||||
};
|
||||
|
||||
export function parseMarketEnvelope(content: string): MarketEnvelope | null {
|
||||
@@ -511,6 +519,13 @@ export function projectMarketChannel(
|
||||
: awards.size > 0
|
||||
? "Awarded"
|
||||
: "Open";
|
||||
let escrowedSats = 0;
|
||||
let settledSats = 0;
|
||||
for (const award of awards.values()) {
|
||||
const total = award.envelope.amountSats * award.envelope.quantity;
|
||||
if (settlements.has(award.note.id)) settledSats += total;
|
||||
else escrowedSats += total;
|
||||
}
|
||||
|
||||
return {
|
||||
channelId,
|
||||
@@ -518,6 +533,7 @@ export function projectMarketChannel(
|
||||
contractAuthorPubkey: listingEntry.note.pubkey,
|
||||
listingEventId,
|
||||
rejected,
|
||||
wallet: { escrowedSats, settledSats },
|
||||
scenario: {
|
||||
id: scenarioId,
|
||||
eyebrow: `${listing.listing.direction === "offer" ? "Offer" : "Request"} · ${listing.listing.mechanism.replace("-", " ")} · ${quantity === "unlimited" ? "unlimited" : "finite"}`,
|
||||
|
||||
@@ -1,67 +1,35 @@
|
||||
import { CircleCheck, Radio, WalletCards, Zap } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import type { MarketScenarioId } from "@/features/market/lib/marketPrototypeData";
|
||||
import type { MarketProjection } from "@/features/market/lib/marketProtocol";
|
||||
import { ProjectContextRail } from "@/features/projects/ui/ProjectContextRail";
|
||||
import { ProjectHomeColumn } from "@/features/projects/ui/ProjectHomeColumn";
|
||||
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
|
||||
import { SIDEBAR_WIDTH_MIN } from "@/shared/layout/sidebarLayout";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
const MARKET_WALLET_WIDTH_KEY = "buzz.desktop.market-wallet-width";
|
||||
|
||||
const WALLET_DETAILS: Record<
|
||||
MarketScenarioId,
|
||||
{
|
||||
account: string;
|
||||
balance: string;
|
||||
balanceAmount: string;
|
||||
settlement: string;
|
||||
}
|
||||
> = {
|
||||
finite: {
|
||||
account: "escrow1report…13c8",
|
||||
balance: "Funded for 3 reports",
|
||||
balanceAmount: "150",
|
||||
settlement: "1 paid · 2 reserved",
|
||||
},
|
||||
unlimited: {
|
||||
account: "wallet1mapper…c241",
|
||||
balance: "Available for settlement",
|
||||
balanceAmount: "80",
|
||||
settlement: "760 sats paid",
|
||||
},
|
||||
auction: {
|
||||
account: "escrow1strings…9f10",
|
||||
balance: "Auction reserve funded",
|
||||
balanceAmount: "600",
|
||||
settlement: "Held until award",
|
||||
},
|
||||
tender: {
|
||||
account: "escrow1tender…1b73",
|
||||
balance: "Tender reward funded",
|
||||
balanceAmount: "2,000",
|
||||
settlement: "Held during selection",
|
||||
},
|
||||
awarded: {
|
||||
account: "escrow1tender…1b73",
|
||||
balance: "Award held in escrow",
|
||||
balanceAmount: "1,750",
|
||||
settlement: "Release on signed receipt",
|
||||
},
|
||||
};
|
||||
|
||||
export function AgentWalletPanel({
|
||||
open,
|
||||
scenarioId,
|
||||
projection,
|
||||
}: {
|
||||
open: boolean;
|
||||
scenarioId: MarketScenarioId;
|
||||
projection: MarketProjection;
|
||||
}) {
|
||||
const width = useThreadPanelWidth(undefined, {
|
||||
minWidthPx: SIDEBAR_WIDTH_MIN,
|
||||
sessionKey: MARKET_WALLET_WIDTH_KEY,
|
||||
});
|
||||
const wallet = WALLET_DETAILS[scenarioId];
|
||||
const { wallet } = projection;
|
||||
const settlement =
|
||||
wallet.settledSats > 0 && wallet.escrowedSats > 0
|
||||
? `${wallet.settledSats} sats paid · ${wallet.escrowedSats} escrowed`
|
||||
: wallet.settledSats > 0
|
||||
? `${wallet.settledSats} fake sats released on signed receipts`
|
||||
: wallet.escrowedSats > 0
|
||||
? "Held until fulfillment is settled"
|
||||
: "No awards yet · nothing escrowed";
|
||||
|
||||
return (
|
||||
<ProjectContextRail
|
||||
@@ -94,16 +62,18 @@ export function AgentWalletPanel({
|
||||
className="absolute -right-6 -top-8 h-24 w-24 rounded-full border-[14px] border-sidebar-foreground/[0.035]"
|
||||
/>
|
||||
<div className="relative flex items-center gap-2 text-xs text-sidebar-foreground/60">
|
||||
<Zap className="h-3.5 w-3.5" /> Sandbox balance
|
||||
<Zap className="h-3.5 w-3.5" /> Sandbox escrow
|
||||
</div>
|
||||
<p className="relative mt-3 flex items-baseline gap-1.5">
|
||||
<span className="text-2xl font-semibold tracking-tight">
|
||||
{wallet.balanceAmount}
|
||||
{wallet.escrowedSats}
|
||||
</span>
|
||||
<span className="text-xs text-sidebar-foreground/60">sats</span>
|
||||
</p>
|
||||
<p className="relative mt-1 text-xs text-sidebar-foreground/60">
|
||||
{wallet.balance}
|
||||
{wallet.escrowedSats > 0
|
||||
? "Awarded and held for settlement"
|
||||
: "Nothing currently escrowed"}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -113,7 +83,7 @@ export function AgentWalletPanel({
|
||||
<p className="text-sm font-medium">Settlement protected</p>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-sidebar-foreground/60">
|
||||
{wallet.settlement}
|
||||
{settlement}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -125,9 +95,9 @@ export function AgentWalletPanel({
|
||||
/>
|
||||
<WalletValue
|
||||
icon={WalletCards}
|
||||
label="Wallet account"
|
||||
label="Contract account"
|
||||
mono
|
||||
value={wallet.account}
|
||||
value={`escrow:${truncatePubkey(projection.listingEventId)}`}
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import * as React from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { ChannelScreen } from "@/features/channels/ui/ChannelScreen";
|
||||
import { MarketChannelProvider } from "@/features/market/lib/MarketChannelContext";
|
||||
import { useMarketChannelProjection } from "@/features/market/lib/useMarketChannels";
|
||||
import type { MarketAnnouncement } from "@/features/market/lib/marketProtocol";
|
||||
import { AgentWalletPanel } from "@/features/market/ui/AgentWalletPanel";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon";
|
||||
import { useOptionalSidebar } from "@/shared/ui/sidebar";
|
||||
|
||||
export function MarketChannelHome({
|
||||
activeChannel,
|
||||
@@ -15,12 +21,50 @@ export function MarketChannelHome({
|
||||
announcement: MarketAnnouncement;
|
||||
}) {
|
||||
const projection = useMarketChannelProjection(activeChannel, announcement);
|
||||
const sidebar = useOptionalSidebar();
|
||||
const [walletOpen, setWalletOpen] = React.useState(true);
|
||||
if (!projection)
|
||||
return <ChannelScreen activeChannel={activeChannel} {...screenProps} />;
|
||||
|
||||
const walletToggle = (
|
||||
<Button
|
||||
aria-label={walletOpen ? "Hide agent wallet" : "Show agent wallet"}
|
||||
aria-pressed={walletOpen}
|
||||
className="h-7 w-7 text-sidebar-foreground hover:bg-sidebar-accent"
|
||||
data-testid="market-wallet-toggle"
|
||||
onClick={() => setWalletOpen((open) => !open)}
|
||||
size="icon"
|
||||
title={walletOpen ? "Hide agent wallet" : "Show agent wallet"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<DrawerPanelIcon
|
||||
className="-scale-x-100"
|
||||
side={walletOpen ? "left" : "right"}
|
||||
testId="market-wallet-toggle-icon"
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<MarketChannelProvider projection={projection}>
|
||||
<ChannelScreen activeChannel={activeChannel} {...screenProps} />
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-sidebar pb-2 pr-2 pt-px",
|
||||
sidebar?.open === false && "pl-2",
|
||||
)}
|
||||
data-buzz-context-detached="true"
|
||||
data-testid="market-channel-home"
|
||||
>
|
||||
<div className="ml-px flex min-h-0 min-w-60 flex-1 flex-col overflow-hidden rounded-2xl bg-background">
|
||||
<ChannelScreen
|
||||
{...screenProps}
|
||||
activeChannel={activeChannel}
|
||||
headerEndActions={walletToggle}
|
||||
/>
|
||||
</div>
|
||||
<AgentWalletPanel open={walletOpen} projection={projection} />
|
||||
</div>
|
||||
</MarketChannelProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,75 @@
|
||||
import { useMarketChannel } from "@/features/market/lib/MarketChannelContext";
|
||||
import { presentMarketEvent } from "@/features/market/lib/marketEventPresentation";
|
||||
import { parseMarketEnvelope } from "@/features/market/lib/marketProtocol";
|
||||
import { MarketEventCard } from "@/features/market/ui/MarketEventCard";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
|
||||
export function MarketMessageBody(content: string, authorPubkey?: string) {
|
||||
export function MarketMessageBody(
|
||||
content: string,
|
||||
authorPubkey?: string,
|
||||
eventId?: string,
|
||||
) {
|
||||
return parseMarketEnvelope(content) ? (
|
||||
<MarketEventCard authorPubkey={authorPubkey ?? ""} content={content} />
|
||||
<MarketEventMessage
|
||||
authorPubkey={authorPubkey ?? ""}
|
||||
content={content}
|
||||
eventId={eventId}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inside a market channel, lifecycle envelopes read as ordinary chat
|
||||
* messages: the agent's words as the body, with a small inline badge naming
|
||||
* the signed transition. Outside market channels (Pulse, forwarded events)
|
||||
* the full card treatment remains.
|
||||
*/
|
||||
function MarketEventMessage({
|
||||
authorPubkey,
|
||||
content,
|
||||
eventId,
|
||||
}: {
|
||||
authorPubkey: string;
|
||||
content: string;
|
||||
eventId?: string;
|
||||
}) {
|
||||
const projection = useMarketChannel();
|
||||
const envelope = parseMarketEnvelope(content);
|
||||
if (!envelope) return null;
|
||||
if (!projection) {
|
||||
return <MarketEventCard authorPubkey={authorPubkey} content={content} />;
|
||||
}
|
||||
|
||||
const presentation = presentMarketEvent(envelope);
|
||||
const rejection = eventId
|
||||
? projection.rejected.find((entry) => entry.eventId === eventId)
|
||||
: undefined;
|
||||
const body =
|
||||
envelope.type === "contract"
|
||||
? `Opened this market: ${envelope.listing.title}`
|
||||
: presentation.summary;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-w-0 max-w-full"
|
||||
data-testid={`market-message-${envelope.type}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge className="capitalize" variant="secondary">
|
||||
{presentation.eyebrow}
|
||||
</Badge>
|
||||
{presentation.amount ? (
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{presentation.amount}
|
||||
</span>
|
||||
) : null}
|
||||
{rejection ? (
|
||||
<Badge data-testid="market-message-rejected" variant="destructive">
|
||||
Rejected · {rejection.reason}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 max-w-full text-message">{body}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -409,7 +409,11 @@ export const MessageRow = React.memo(
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
const marketBody = MarketMessageBody(message.body, message.pubkey);
|
||||
const marketBody = MarketMessageBody(
|
||||
message.body,
|
||||
message.pubkey,
|
||||
message.id,
|
||||
);
|
||||
if (marketBody) return marketBody;
|
||||
const waveMessage = parseWaveMessageContent(message.body);
|
||||
if (waveMessage) {
|
||||
|
||||
Reference in New Issue
Block a user